Extract snatch selection into reusable composable (#30)
## Summary - add a useSnatchSelection composable that centralises snatch shortfall calculations, position filtering, and RPC memoisation - refactor StakeHolder.vue to consume the composable instead of reimplementing the flow inline - introduce Vitest config and first composable tests (useSnatchSelection.spec.ts) to cover empty/partial fills and ownership edge cases - wire up project tooling updates so the new tests run (jsdom dep, updated package metadata) ## Testing - cd web-app && npm install - npm test resolves #24 Co-authored-by: openhands <openhands@all-hands.dev> Reviewed-on: https://codeberg.org/johba/harb/pulls/30
This commit is contained in:
parent
76d84341de
commit
26a8771848
6 changed files with 1202 additions and 311 deletions
|
|
@ -1,127 +1,95 @@
|
|||
<template>
|
||||
<div class="hold-inner">
|
||||
<div class="stake-inner">
|
||||
<template v-if="!statCollection.initialized">
|
||||
<div>
|
||||
<f-loader></f-loader>
|
||||
</div>
|
||||
</template>
|
||||
<div class="hold-inner">
|
||||
<div class="stake-inner">
|
||||
<template v-if="!statCollection.initialized">
|
||||
<div>
|
||||
<f-loader></f-loader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="subheader2">Token Amount</div>
|
||||
<FSlider :min="minStakeAmount" :max="maxStakeAmount" v-model="stake.stakingAmountNumber"></FSlider>
|
||||
<div class="formular">
|
||||
<div class="row row-1">
|
||||
<f-input label="Staking Amount" class="staking-amount" v-model="stake.stakingAmountNumber">
|
||||
<template v-slot:details>
|
||||
<div class="balance">Balance: {{ maxStakeAmount.toFixed(2) }} $KRK</div>
|
||||
<div @click="setMaxAmount" class="staking-amount-max">
|
||||
<b>Max</b>
|
||||
</div>
|
||||
</template>
|
||||
</f-input>
|
||||
<Icon class="stake-arrow" icon="mdi:chevron-triple-right"></Icon>
|
||||
<f-input
|
||||
label="Owner Slots"
|
||||
class="staking-amount"
|
||||
disabled
|
||||
:modelValue="`${stakeSlots}(${supplyFreeze?.toFixed(4)})`"
|
||||
>
|
||||
<template #info>
|
||||
Slots correspond to a percentage of ownership in the protocol.<br /><br />1,000 Slots =
|
||||
1% Ownership<br /><br />When you unstake you get the exact percentage of the current
|
||||
$KRK total supply. When the total supply increased since you staked you get more tokens
|
||||
back than before.
|
||||
</template>
|
||||
</f-input>
|
||||
<!-- <f-select :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRate">
|
||||
<template v-slot:info>
|
||||
The tax you have to pay to keep your staking position open. The tax is
|
||||
calculated on a yearly basis but paid continuously.
|
||||
</template>
|
||||
</f-select> -->
|
||||
<template v-else>
|
||||
<div class="subheader2">Token Amount</div>
|
||||
<FSlider :min="minStakeAmount" :max="maxStakeAmount" v-model="stake.stakingAmountNumber"></FSlider>
|
||||
<div class="formular">
|
||||
<div class="row row-1">
|
||||
<f-input label="Staking Amount" class="staking-amount" v-model="stake.stakingAmountNumber">
|
||||
<template v-slot:details>
|
||||
<div class="balance">Balance: {{ maxStakeAmount.toFixed(2) }} $KRK</div>
|
||||
<div @click="setMaxAmount" class="staking-amount-max">
|
||||
<b>Max</b>
|
||||
</div>
|
||||
</template>
|
||||
</f-input>
|
||||
<Icon class="stake-arrow" icon="mdi:chevron-triple-right"></Icon>
|
||||
<f-input
|
||||
label="Owner Slots"
|
||||
class="staking-amount"
|
||||
disabled
|
||||
:modelValue="`${stakeSlots}(${supplyFreeze?.toFixed(4)})`"
|
||||
>
|
||||
<template #info>
|
||||
Slots correspond to a percentage of ownership in the protocol.<br /><br />1,000 Slots =
|
||||
1% Ownership<br /><br />When you unstake you get the exact percentage of the current
|
||||
$KRK total supply. When the total supply increased since you staked you get more tokens
|
||||
back than before.
|
||||
</template>
|
||||
</f-input>
|
||||
</div>
|
||||
<div class="row row-2">
|
||||
<f-select :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRate">
|
||||
<template v-slot:info>
|
||||
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking
|
||||
or manually in the dashboard. If someone pays a higher tax they can buy you out.
|
||||
</template>
|
||||
</f-select>
|
||||
<f-input label="Floor Tax" disabled :modelValue="snatchSelection.floorTax">
|
||||
<template v-slot:info>
|
||||
This is the current minimum tax you have to pay to claim owner slots from other owners.
|
||||
</template>
|
||||
</f-input>
|
||||
<f-input label="Positions Buyout" disabled :modelValue="snatchSelection.snatchablePositions.length">
|
||||
<template v-slot:info>
|
||||
This shows you the numbers of staking positions you buy out from current owners by
|
||||
paying a higher tax. If you get bought out yourself by new owners you get paid out the
|
||||
current market value of your position incl. your profits.
|
||||
</template>
|
||||
</f-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-2">
|
||||
<f-select :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRate">
|
||||
<template v-slot:info>
|
||||
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking
|
||||
or manually in the dashboard. If someone pays a higher tax they can buy you out.
|
||||
</template>
|
||||
</f-select>
|
||||
<f-input label="Floor Tax" disabled :modelValue="floorTax">
|
||||
<template v-slot:info>
|
||||
This is the current minimum tax you have to pay to claim owner slots from other owners.
|
||||
</template>
|
||||
</f-input>
|
||||
<f-input label="Positions Buyout" disabled :modelValue="snatchAblePositions.length">
|
||||
<template v-slot:info>
|
||||
This shows you the numbers of staking positions you buy out from current owners by
|
||||
paying a higher tax. If you get bought out yourself by new owners you get paid out the
|
||||
current market value of your position incl. your profits.
|
||||
</template>
|
||||
</f-input>
|
||||
</div>
|
||||
</div>
|
||||
<f-button size="large" disabled block v-if="stake.state === 'NoBalance'">Insufficient Balance</f-button>
|
||||
<f-button size="large" disabled block v-else-if="stake.stakingAmountNumber < minStakeAmount"
|
||||
>Stake amount too low</f-button
|
||||
>
|
||||
<f-button
|
||||
size="large"
|
||||
disabled
|
||||
block
|
||||
v-else-if="
|
||||
!openPositionsAvailable && stake.state === 'StakeAble' && snatchAblePositions.length === 0
|
||||
"
|
||||
>taxRate too low to snatch</f-button
|
||||
>
|
||||
<f-button
|
||||
size="large"
|
||||
block
|
||||
v-else-if="stake.state === 'StakeAble' && snatchAblePositions.length === 0"
|
||||
@click="stakeSnatch"
|
||||
>Stake</f-button
|
||||
>
|
||||
<f-button
|
||||
size="large"
|
||||
block
|
||||
v-else-if="stake.state === 'StakeAble' && snatchAblePositions.length > 0"
|
||||
@click="stakeSnatch"
|
||||
>Snatch and Stake</f-button
|
||||
>
|
||||
<f-button size="large" outlined block v-else-if="stake.state === 'SignTransaction'"
|
||||
>Sign Transaction ...</f-button
|
||||
>
|
||||
<f-button size="large" outlined block v-else-if="stake.state === 'Waiting'">Waiting ...</f-button>
|
||||
</template>
|
||||
<f-button size="large" disabled block v-if="stake.state === 'NoBalance'">Insufficient Balance</f-button>
|
||||
<f-button size="large" disabled block v-else-if="stake.stakingAmountNumber < minStakeAmount"
|
||||
>Stake amount too low</f-button
|
||||
>
|
||||
<f-button
|
||||
size="large"
|
||||
disabled
|
||||
block
|
||||
v-else-if="
|
||||
!snatchSelection.openPositionsAvailable && stake.state === 'StakeAble' && snatchSelection.snatchablePositions.length === 0
|
||||
"
|
||||
>taxRate too low to snatch</f-button
|
||||
>
|
||||
<f-button
|
||||
size="large"
|
||||
block
|
||||
v-else-if="stake.state === 'StakeAble' && snatchSelection.snatchablePositions.length === 0"
|
||||
@click="stakeSnatch"
|
||||
>Stake</f-button
|
||||
>
|
||||
<f-button
|
||||
size="large"
|
||||
block
|
||||
v-else-if="stake.state === 'StakeAble' && snatchSelection.snatchablePositions.length > 0"
|
||||
@click="stakeSnatch"
|
||||
>Snatch and Stake</f-button
|
||||
>
|
||||
<f-button size="large" outlined block v-else-if="stake.state === 'SignTransaction'"
|
||||
>Sign Transaction ...</f-button
|
||||
>
|
||||
<f-button size="large" outlined block v-else-if="stake.state === 'Waiting'">Waiting ...</f-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <template v-if="myActivePositions.length > 0 && status === 'connected'">
|
||||
<h5>Your Active Positions</h5>
|
||||
<collapse-active
|
||||
v-for="position in myActivePositions"
|
||||
:taxRate="position.taxRatePercentage"
|
||||
:amount="position.amount"
|
||||
:treshold="tresholdValue"
|
||||
:id="position.positionId"
|
||||
:position="position"
|
||||
:key="position.id"
|
||||
></collapse-active>
|
||||
</template>
|
||||
<template v-if="myClosedPositions.length > 0 && status === 'connected'">
|
||||
<h5>History</h5>
|
||||
<collapse-history
|
||||
v-for="position in myClosedPositions"
|
||||
:taxRate="position.taxRatePercentage"
|
||||
:taxPaid="formatBigNumber(position.taxPaid, 18)"
|
||||
:amount="position.amount"
|
||||
:treshold="tresholdValue"
|
||||
:id="position.positionId"
|
||||
:key="position.id"
|
||||
:position="position"
|
||||
></collapse-history>
|
||||
</template> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -133,26 +101,12 @@ import FSlider from "@/components/fcomponents/FSlider.vue";
|
|||
import FOutput from "@/components/fcomponents/FOutput.vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import { formatBigIntDivision, InsertCommaNumber, formatBigNumber, bigInt2Number } from "@/utils/helper";
|
||||
// import StatsOutput from "@/components/molecules/StatsOutput.vue";
|
||||
// import ChartJs from "@/components/ChartJs.vue";
|
||||
// import CollapseActive from "@/components/collapse/CollapseActive.vue";
|
||||
// import CollapseHistory from "@/components/collapse/CollapseHistory.vue";
|
||||
// import { bytesToUint256, uint256ToBytes } from "harb-lib";
|
||||
// import { getSnatchList } from "harb-lib/dist/";
|
||||
import { formatUnits } from "viem";
|
||||
import { loadPositions, usePositions, type Position } from "@/composables/usePositions";
|
||||
import {
|
||||
calculateSnatchShortfall,
|
||||
selectSnatchPositions,
|
||||
minimumTaxRate,
|
||||
type SnatchablePosition,
|
||||
} from "kraiken-lib";
|
||||
|
||||
import { loadPositions, usePositions } from "@/composables/usePositions";
|
||||
import { useStake } from "@/composables/useStake";
|
||||
import { useClaim } from "@/composables/useClaim";
|
||||
import { useAdjustTaxRate } from "@/composables/useAdjustTaxRates";
|
||||
|
||||
import { assetsToShares } from "@/contracts/stake";
|
||||
import { useSnatchSelection } from "@/composables/useSnatchSelection";
|
||||
import { getMinStake } from "@/contracts/harb";
|
||||
import { useWallet } from "@/composables/useWallet";
|
||||
import { ref, onMounted, watch, computed, inject, watchEffect } from "vue";
|
||||
|
|
@ -166,9 +120,7 @@ const adjustTaxRate = useAdjustTaxRate();
|
|||
|
||||
const activeTab = ref("stake");
|
||||
const StakeMenuOpen = ref(false);
|
||||
// let minStakeAmount = ref();
|
||||
const taxRate = ref<number>(1.0);
|
||||
// const positions = ref<Array<any>>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
const stakeSnatchLoading = ref<boolean>(false);
|
||||
const stake = useStake();
|
||||
|
|
@ -178,11 +130,9 @@ const statCollection = useStatCollection();
|
|||
|
||||
const { activePositions } = usePositions();
|
||||
|
||||
const floorTax = ref(1);
|
||||
const minStake = ref(0n);
|
||||
const stakeSlots = ref();
|
||||
const supplyFreeze = ref<number>(0);
|
||||
const shortfallShares = ref<bigint>(0n);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
watchEffect(() => {
|
||||
console.log("supplyFreeze");
|
||||
|
|
@ -204,18 +154,11 @@ watchEffect(() => {
|
|||
}, 500); // Verzögerung von 500ms
|
||||
});
|
||||
|
||||
const openPositionsAvailable = computed(() => shortfallShares.value <= 0n);
|
||||
|
||||
watchEffect(() => {
|
||||
console.log("stakeSlots");
|
||||
stakeSlots.value = (supplyFreeze.value * 1000)?.toFixed(2);
|
||||
});
|
||||
|
||||
// const stakeAbleHarbAmount = computed(() => statCollection.kraikenTotalSupply / 5n);
|
||||
//war das mal so, wurde das geändert --> funktioniert nicht mehr
|
||||
// const minStake = computed(() => stakeAbleHarbAmount.value / 600n);
|
||||
|
||||
|
||||
const tokenIssuance = computed(() => {
|
||||
if (statCollection.kraikenTotalSupply === 0n) {
|
||||
return 0n;
|
||||
|
|
@ -224,16 +167,11 @@ const tokenIssuance = computed(() => {
|
|||
return (statCollection.nettoToken7d / statCollection.kraikenTotalSupply) * 100n;
|
||||
});
|
||||
|
||||
function getMinFloorTax() {
|
||||
const minRate = minimumTaxRate(activePositions.value, 0);
|
||||
return Math.round(minRate * 100);
|
||||
}
|
||||
|
||||
async function stakeSnatch() {
|
||||
if (snatchAblePositions.value.length === 0) {
|
||||
if (snatchSelection.snatchablePositions.value.length === 0) {
|
||||
await stake.snatch(stake.stakingAmount, taxRate.value);
|
||||
} else {
|
||||
const snatchAblePositionsIds = snatchAblePositions.value.map((p: Position) => p.positionId);
|
||||
const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId);
|
||||
await stake.snatch(stake.stakingAmount, taxRate.value, snatchAblePositionsIds);
|
||||
}
|
||||
stakeSnatchLoading.value = true;
|
||||
|
|
@ -249,16 +187,7 @@ watch(
|
|||
console.log("to", to.hash);
|
||||
if (to.hash === "#stake") {
|
||||
console.log("StakeMenuOpen", StakeMenuOpen.value);
|
||||
|
||||
StakeMenuOpen.value = true;
|
||||
|
||||
// if(element){
|
||||
// element.scrollIntoView({behavior: "smooth"});
|
||||
// }
|
||||
|
||||
// if(ele){
|
||||
// window.scrollTo(ele.offsetLeft,ele.offsetTop);
|
||||
// }
|
||||
}
|
||||
},
|
||||
{ flush: "pre", immediate: true, deep: true }
|
||||
|
|
@ -266,12 +195,6 @@ watch(
|
|||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
//on AccountChange
|
||||
//TODO
|
||||
// await getTotalSupply();
|
||||
// await getTotalSupplyHarb();
|
||||
// await getOutstandingSupply();
|
||||
|
||||
minStake.value = await getMinStake();
|
||||
stake.stakingAmountNumber = minStakeAmount.value;
|
||||
} catch (error) {
|
||||
|
|
@ -280,32 +203,16 @@ onMounted(async () => {
|
|||
loading.value = false;
|
||||
}
|
||||
});
|
||||
// async function getGraphData(): Promise<Array<any>> {
|
||||
// let res = await axios.post("http://127.0.0.1:42069/graphql", {
|
||||
// query: "query MyQuery {\n positions {\n id\n lastTaxTime\n owner\n share\n status\n taxRate\n creationTime\n }\n}",
|
||||
// });
|
||||
// return res.data.data.positions;
|
||||
// }
|
||||
|
||||
// const result = useReadContract({
|
||||
// abi: StakeContract.abi,
|
||||
// address: StakeContract.contractAddress,
|
||||
// functionName: "minStake",
|
||||
// args: [],
|
||||
// });
|
||||
// minStakeAmount = result.data;
|
||||
|
||||
const minStakeAmount = computed(() => {
|
||||
console.log("minStake", minStake.value);
|
||||
|
||||
return bigInt2Number(minStake.value, 18);
|
||||
});
|
||||
|
||||
const maxStakeAmount = computed(() => {
|
||||
if (wallet.balance?.value) {
|
||||
// return Number(balance?.value?.value / 10n ** BigInt(balance?.value!.decimals));
|
||||
console.log("wallet.balance.value", wallet.balance);
|
||||
console.log("formatBigIntDivision(wallet.balance.value, 10n ** 18n)", bigInt2Number(wallet.balance.value, 18));
|
||||
|
||||
return bigInt2Number(wallet.balance.value, 18);
|
||||
} else {
|
||||
return 0;
|
||||
|
|
@ -325,128 +232,10 @@ watch(
|
|||
|
||||
function setMaxAmount() {
|
||||
console.log("maxStakeAmount.value", maxStakeAmount.value);
|
||||
|
||||
stake.stakingAmountNumber = maxStakeAmount.value;
|
||||
}
|
||||
const snatchAblePositions = ref<Position[]>([]);
|
||||
let selectionRun = 0;
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const runId = ++selectionRun;
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
const compute = async () => {
|
||||
if (statCollection.stakeTotalSupply === 0n) {
|
||||
shortfallShares.value = 0n;
|
||||
if (!cancelled && runId === selectionRun) {
|
||||
snatchAblePositions.value = [];
|
||||
floorTax.value = getMinFloorTax();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stakingShares = stake.stakingAmountShares ?? 0n;
|
||||
const shortfall = calculateSnatchShortfall(
|
||||
statCollection.outstandingStake,
|
||||
stakingShares,
|
||||
statCollection.stakeTotalSupply,
|
||||
2n,
|
||||
10n
|
||||
);
|
||||
|
||||
shortfallShares.value = shortfall;
|
||||
|
||||
if (shortfall <= 0n) {
|
||||
if (!cancelled && runId === selectionRun) {
|
||||
snatchAblePositions.value = [];
|
||||
floorTax.value = getMinFloorTax();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const maxTaxRateDecimal = (taxRate.value ?? 0) / 100;
|
||||
const includeOwned = demo;
|
||||
const recipient = wallet.account.address ?? null;
|
||||
|
||||
const eligiblePositions = activePositions.value.filter((position: Position) => {
|
||||
if (position.taxRate >= maxTaxRateDecimal) {
|
||||
return false;
|
||||
}
|
||||
if (!includeOwned && position.iAmOwner) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligiblePositions.length === 0) {
|
||||
if (!cancelled && runId === selectionRun) {
|
||||
snatchAblePositions.value = [];
|
||||
floorTax.value = getMinFloorTax();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates: SnatchablePosition[] = [];
|
||||
|
||||
for (const position of eligiblePositions) {
|
||||
try {
|
||||
const shares = await assetsToShares(position.harbDeposit);
|
||||
candidates.push({
|
||||
id: position.positionId,
|
||||
owner: position.owner,
|
||||
stakeShares: shares,
|
||||
taxRate: position.taxRate,
|
||||
taxRateIndex: position.taxRateIndex,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Unable to compute stake shares for position", position.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
const selection = selectSnatchPositions(candidates, {
|
||||
shortfallShares: shortfall,
|
||||
maxTaxRate: maxTaxRateDecimal,
|
||||
includeOwned,
|
||||
recipientAddress: recipient,
|
||||
});
|
||||
|
||||
if (cancelled || runId !== selectionRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.remainingShortfall > 0n) {
|
||||
snatchAblePositions.value = [];
|
||||
floorTax.value = getMinFloorTax();
|
||||
return;
|
||||
}
|
||||
|
||||
const positionById = new Map<bigint, Position>();
|
||||
for (const position of activePositions.value) {
|
||||
positionById.set(position.positionId, position);
|
||||
}
|
||||
|
||||
const selectedPositions = selection.selected
|
||||
.map((candidate) => positionById.get(candidate.id))
|
||||
.filter((value): value is Position => Boolean(value));
|
||||
|
||||
snatchAblePositions.value = selectedPositions;
|
||||
|
||||
if (selection.maxSelectedTaxRateIndex !== undefined) {
|
||||
const nextIndex = selection.maxSelectedTaxRateIndex + 1;
|
||||
const option =
|
||||
adjustTaxRate.taxRates[nextIndex] ??
|
||||
adjustTaxRate.taxRates[selection.maxSelectedTaxRateIndex];
|
||||
floorTax.value = option ? option.year : getMinFloorTax();
|
||||
} else {
|
||||
floorTax.value = getMinFloorTax();
|
||||
}
|
||||
};
|
||||
|
||||
compute();
|
||||
});
|
||||
const snatchSelection = useSnatchSelection(demo);
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
|
@ -482,11 +271,7 @@ watchEffect((onCleanup) => {
|
|||
justify-content: space-between
|
||||
>*
|
||||
flex: 0 0 30%
|
||||
// >:nth-child(2)
|
||||
// flex: 0 0 22%
|
||||
// >:nth-child(3)
|
||||
// flex: 0 1 28%
|
||||
.stake-arrow
|
||||
align-self: center
|
||||
font-size: 30px
|
||||
</style>
|
||||
</style>
|
||||
274
web-app/src/composables/__tests__/useSnatchSelection.spec.ts
Normal file
274
web-app/src/composables/__tests__/useSnatchSelection.spec.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useSnatchSelection } from '../useSnatchSelection'
|
||||
import { usePositions } from '../usePositions'
|
||||
import { useStake } from '../useStake'
|
||||
import { useWallet } from '../useWallet'
|
||||
import { useStatCollection } from '../useStatCollection'
|
||||
import { useAdjustTaxRate } from '../useAdjustTaxRates'
|
||||
|
||||
// Mock all composables
|
||||
vi.mock('../usePositions', () => ({
|
||||
usePositions: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../useStake', () => ({
|
||||
useStake: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../useWallet', () => ({
|
||||
useWallet: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../useStatCollection', () => ({
|
||||
useStatCollection: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../useAdjustTaxRates', () => ({
|
||||
useAdjustTaxRate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('kraiken-lib', () => ({
|
||||
calculateSnatchShortfall: vi.fn((outstandingStake, stakingShares, stakeTotalSupply) => {
|
||||
return stakingShares > outstandingStake ? 0n : outstandingStake - stakingShares
|
||||
}),
|
||||
selectSnatchPositions: vi.fn((candidates, options) => {
|
||||
if (candidates.length === 0) {
|
||||
return { selected: [], remainingShortfall: options.shortfallShares }
|
||||
}
|
||||
return {
|
||||
selected: candidates,
|
||||
remainingShortfall: 0n,
|
||||
maxSelectedTaxRateIndex: candidates[candidates.length - 1].taxRateIndex
|
||||
}
|
||||
}),
|
||||
minimumTaxRate: vi.fn(() => 0.01)
|
||||
}))
|
||||
|
||||
describe('useSnatchSelection', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup default mock values with proper refs
|
||||
vi.mocked(usePositions).mockReturnValue({
|
||||
activePositions: ref([])
|
||||
} as any)
|
||||
|
||||
vi.mocked(useStake).mockReturnValue({
|
||||
stakingAmountShares: 0n,
|
||||
taxRate: 1.0
|
||||
} as any)
|
||||
|
||||
vi.mocked(useWallet).mockReturnValue({
|
||||
account: { address: '0x123' }
|
||||
} as any)
|
||||
|
||||
// Mock with realistic values for local computation
|
||||
// stakeTotalSupply is typically 10^(18+7) = 10^25 from contract
|
||||
// kraikenTotalSupply is dynamic but starts around 10^25
|
||||
vi.mocked(useStatCollection).mockReturnValue({
|
||||
stakeTotalSupply: 10000000000000000000000000n, // 10^25
|
||||
kraikenTotalSupply: 10000000000000000000000000n, // 10^25
|
||||
outstandingStake: 500n
|
||||
} as any)
|
||||
|
||||
vi.mocked(useAdjustTaxRate).mockReturnValue({
|
||||
taxRates: [{ year: 1 }]
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should initialize with empty snatchable positions', () => {
|
||||
const { snatchablePositions } = useSnatchSelection()
|
||||
expect(snatchablePositions.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle no active positions', () => {
|
||||
const { snatchablePositions, floorTax } = useSnatchSelection()
|
||||
expect(snatchablePositions.value).toEqual([])
|
||||
expect(floorTax.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle no shortfall', () => {
|
||||
vi.mocked(useStatCollection).mockReturnValue({
|
||||
stakeTotalSupply: 1000n,
|
||||
outstandingStake: 100n
|
||||
})
|
||||
|
||||
vi.mocked(useStake).mockReturnValue({
|
||||
stakingAmountShares: 900n,
|
||||
taxRate: 1.0
|
||||
})
|
||||
|
||||
const { snatchablePositions, openPositionsAvailable } = useSnatchSelection()
|
||||
expect(snatchablePositions.value).toEqual([])
|
||||
expect(openPositionsAvailable.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should filter out positions with higher tax rate', async () => {
|
||||
vi.mocked(usePositions).mockReturnValue({
|
||||
activePositions: ref([
|
||||
{
|
||||
positionId: 1n,
|
||||
owner: '0x456',
|
||||
harbDeposit: 100n,
|
||||
taxRate: 2.0,
|
||||
taxRateIndex: 1,
|
||||
iAmOwner: false
|
||||
}
|
||||
])
|
||||
} as any)
|
||||
|
||||
vi.mocked(useStake).mockReturnValue({
|
||||
stakingAmountShares: 100n,
|
||||
taxRate: 1.0
|
||||
} as any)
|
||||
|
||||
const { snatchablePositions } = useSnatchSelection()
|
||||
|
||||
// Wait for watchEffect to run
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(snatchablePositions.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should filter out owned positions by default', async () => {
|
||||
vi.mocked(usePositions).mockReturnValue({
|
||||
activePositions: ref([
|
||||
{
|
||||
positionId: 1n,
|
||||
owner: '0x123',
|
||||
harbDeposit: 100n,
|
||||
taxRate: 0.5,
|
||||
taxRateIndex: 1,
|
||||
iAmOwner: true
|
||||
}
|
||||
])
|
||||
} as any)
|
||||
|
||||
const { snatchablePositions } = useSnatchSelection()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(snatchablePositions.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should include owned positions when demo mode is enabled', async () => {
|
||||
const position = {
|
||||
positionId: 1n,
|
||||
owner: '0x123',
|
||||
harbDeposit: 100n,
|
||||
taxRate: 0.005, // 0.5% tax rate (less than maxTaxRate)
|
||||
taxRateIndex: 1,
|
||||
iAmOwner: true
|
||||
}
|
||||
|
||||
vi.mocked(usePositions).mockReturnValue({
|
||||
activePositions: ref([position])
|
||||
} as any)
|
||||
|
||||
vi.mocked(useStake).mockReturnValue({
|
||||
stakingAmountShares: 100n,
|
||||
taxRate: 1.0 // Will be converted to 0.01 (1%) decimal
|
||||
} as any)
|
||||
|
||||
// Need outstandingStake > stakingAmountShares to create shortfall
|
||||
vi.mocked(useStatCollection).mockReturnValue({
|
||||
stakeTotalSupply: 10000000000000000000000000n,
|
||||
kraikenTotalSupply: 10000000000000000000000000n,
|
||||
outstandingStake: 500n
|
||||
} as any)
|
||||
|
||||
const { snatchablePositions } = useSnatchSelection(true)
|
||||
|
||||
// Wait for watchEffect to run (no longer async)
|
||||
await nextTick()
|
||||
|
||||
expect(snatchablePositions.value).toContainEqual(position)
|
||||
})
|
||||
|
||||
it('should handle partial fills', async () => {
|
||||
const position1 = {
|
||||
positionId: 1n,
|
||||
owner: '0x456',
|
||||
harbDeposit: 100n,
|
||||
taxRate: 0.005, // 0.5% tax rate
|
||||
taxRateIndex: 1,
|
||||
iAmOwner: false
|
||||
}
|
||||
|
||||
const position2 = {
|
||||
positionId: 2n,
|
||||
owner: '0x789',
|
||||
harbDeposit: 200n,
|
||||
taxRate: 0.006, // 0.6% tax rate
|
||||
taxRateIndex: 2,
|
||||
iAmOwner: false
|
||||
}
|
||||
|
||||
vi.mocked(usePositions).mockReturnValue({
|
||||
activePositions: ref([position1, position2])
|
||||
} as any)
|
||||
|
||||
vi.mocked(useStake).mockReturnValue({
|
||||
stakingAmountShares: 150n,
|
||||
taxRate: 1.0 // Will be converted to 0.01 (1%) decimal
|
||||
} as any)
|
||||
|
||||
// Need outstandingStake > stakingAmountShares to create shortfall
|
||||
vi.mocked(useStatCollection).mockReturnValue({
|
||||
stakeTotalSupply: 10000000000000000000000000n,
|
||||
kraikenTotalSupply: 10000000000000000000000000n,
|
||||
outstandingStake: 500n
|
||||
} as any)
|
||||
|
||||
const { snatchablePositions } = useSnatchSelection()
|
||||
|
||||
// Wait for watchEffect to run
|
||||
await nextTick()
|
||||
|
||||
expect(snatchablePositions.value).toEqual([position1, position2])
|
||||
})
|
||||
|
||||
it('should update floor tax based on selected positions', async () => {
|
||||
const position = {
|
||||
positionId: 1n,
|
||||
owner: '0x456',
|
||||
harbDeposit: 100n,
|
||||
taxRate: 0.005, // 0.5% tax rate
|
||||
taxRateIndex: 1,
|
||||
iAmOwner: false
|
||||
}
|
||||
|
||||
vi.mocked(usePositions).mockReturnValue({
|
||||
activePositions: ref([position])
|
||||
} as any)
|
||||
|
||||
vi.mocked(useStake).mockReturnValue({
|
||||
stakingAmountShares: 100n,
|
||||
taxRate: 1.0 // Will be converted to 0.01 (1%) decimal
|
||||
} as any)
|
||||
|
||||
// Need outstandingStake > stakingAmountShares to create shortfall
|
||||
vi.mocked(useStatCollection).mockReturnValue({
|
||||
stakeTotalSupply: 10000000000000000000000000n,
|
||||
kraikenTotalSupply: 10000000000000000000000000n,
|
||||
outstandingStake: 500n
|
||||
} as any)
|
||||
|
||||
vi.mocked(useAdjustTaxRate).mockReturnValue({
|
||||
taxRates: [
|
||||
{ year: 1 },
|
||||
{ year: 2 },
|
||||
{ year: 3 }
|
||||
]
|
||||
} as any)
|
||||
|
||||
const { floorTax } = useSnatchSelection()
|
||||
|
||||
// Wait for watchEffect to run
|
||||
await nextTick()
|
||||
|
||||
// Floor tax should be taxRates[maxSelectedTaxRateIndex + 1]
|
||||
// Position has taxRateIndex: 1, so nextIndex = 2, taxRates[2] = { year: 3 }
|
||||
expect(floorTax.value).toBe(3)
|
||||
})
|
||||
})
|
||||
180
web-app/src/composables/useSnatchSelection.ts
Normal file
180
web-app/src/composables/useSnatchSelection.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { ref, watchEffect, computed } from 'vue'
|
||||
import { usePositions, type Position } from './usePositions'
|
||||
import { useStake } from './useStake'
|
||||
import { useWallet } from './useWallet'
|
||||
import { useStatCollection } from './useStatCollection'
|
||||
import { useAdjustTaxRate } from './useAdjustTaxRates'
|
||||
import {
|
||||
calculateSnatchShortfall,
|
||||
selectSnatchPositions,
|
||||
minimumTaxRate,
|
||||
type SnatchablePosition,
|
||||
} from 'kraiken-lib'
|
||||
|
||||
/**
|
||||
* Converts Kraiken token assets to shares using the same formula as Stake.sol:
|
||||
* shares = (assets * stakeTotalSupply) / kraikenTotalSupply
|
||||
*
|
||||
* @param assets - Amount of Kraiken tokens
|
||||
* @param kraikenTotalSupply - Total supply of Kraiken tokens
|
||||
* @param stakeTotalSupply - Total supply of stake shares (constant from contract)
|
||||
* @returns Number of shares corresponding to the assets
|
||||
*/
|
||||
function assetsToSharesLocal(
|
||||
assets: bigint,
|
||||
kraikenTotalSupply: bigint,
|
||||
stakeTotalSupply: bigint
|
||||
): bigint {
|
||||
if (kraikenTotalSupply === 0n) {
|
||||
return 0n
|
||||
}
|
||||
// Equivalent to: assets.mulDiv(stakeTotalSupply, kraikenTotalSupply, Math.Rounding.Down)
|
||||
return (assets * stakeTotalSupply) / kraikenTotalSupply
|
||||
}
|
||||
|
||||
|
||||
export function useSnatchSelection(demo = false) {
|
||||
const { activePositions } = usePositions()
|
||||
const stake = useStake()
|
||||
const wallet = useWallet()
|
||||
const statCollection = useStatCollection()
|
||||
const adjustTaxRate = useAdjustTaxRate()
|
||||
|
||||
const snatchablePositions = ref<Position[]>([])
|
||||
const shortfallShares = ref<bigint>(0n)
|
||||
const floorTax = ref(1)
|
||||
let selectionRun = 0
|
||||
|
||||
const openPositionsAvailable = computed(() => shortfallShares.value <= 0n)
|
||||
|
||||
function getMinFloorTax() {
|
||||
const minRate = minimumTaxRate(activePositions.value, 0)
|
||||
return Math.round(minRate * 100)
|
||||
}
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const runId = ++selectionRun
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
// No longer async since we compute shares locally
|
||||
const compute = () => {
|
||||
if (statCollection.stakeTotalSupply === 0n) {
|
||||
shortfallShares.value = 0n
|
||||
if (!cancelled && runId === selectionRun) {
|
||||
snatchablePositions.value = []
|
||||
floorTax.value = getMinFloorTax()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const stakingShares = stake.stakingAmountShares ?? 0n
|
||||
const shortfall = calculateSnatchShortfall(
|
||||
statCollection.outstandingStake,
|
||||
stakingShares,
|
||||
statCollection.stakeTotalSupply,
|
||||
2n,
|
||||
10n
|
||||
)
|
||||
|
||||
shortfallShares.value = shortfall
|
||||
|
||||
if (shortfall <= 0n) {
|
||||
if (!cancelled && runId === selectionRun) {
|
||||
snatchablePositions.value = []
|
||||
floorTax.value = getMinFloorTax()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const maxTaxRateDecimal = (stake.taxRate ?? 0) / 100
|
||||
const includeOwned = demo
|
||||
const recipient = wallet.account.address ?? null
|
||||
|
||||
const eligiblePositions = activePositions.value.filter((position: Position) => {
|
||||
if (position.taxRate >= maxTaxRateDecimal) {
|
||||
return false
|
||||
}
|
||||
if (!includeOwned && position.iAmOwner) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (eligiblePositions.length === 0) {
|
||||
if (!cancelled && runId === selectionRun) {
|
||||
snatchablePositions.value = []
|
||||
floorTax.value = getMinFloorTax()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const candidates: SnatchablePosition[] = []
|
||||
|
||||
// Compute shares locally using the same formula as Stake.sol
|
||||
for (const position of eligiblePositions) {
|
||||
const shares = assetsToSharesLocal(
|
||||
position.harbDeposit,
|
||||
statCollection.kraikenTotalSupply,
|
||||
statCollection.stakeTotalSupply
|
||||
)
|
||||
candidates.push({
|
||||
id: position.positionId,
|
||||
owner: position.owner,
|
||||
stakeShares: shares,
|
||||
taxRate: position.taxRate,
|
||||
taxRateIndex: position.taxRateIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const selection = selectSnatchPositions(candidates, {
|
||||
shortfallShares: shortfall,
|
||||
maxTaxRate: maxTaxRateDecimal,
|
||||
includeOwned,
|
||||
recipientAddress: recipient,
|
||||
})
|
||||
|
||||
if (cancelled || runId !== selectionRun) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selection.remainingShortfall > 0n) {
|
||||
snatchablePositions.value = []
|
||||
floorTax.value = getMinFloorTax()
|
||||
return
|
||||
}
|
||||
|
||||
const positionById = new Map<bigint, Position>()
|
||||
for (const position of activePositions.value) {
|
||||
positionById.set(position.positionId, position)
|
||||
}
|
||||
|
||||
const selectedPositions = selection.selected
|
||||
.map((candidate) => positionById.get(candidate.id))
|
||||
.filter((value): value is Position => Boolean(value))
|
||||
|
||||
snatchablePositions.value = selectedPositions
|
||||
|
||||
if (selection.maxSelectedTaxRateIndex !== undefined) {
|
||||
const nextIndex = selection.maxSelectedTaxRateIndex + 1
|
||||
const option =
|
||||
adjustTaxRate.taxRates[nextIndex] ??
|
||||
adjustTaxRate.taxRates[selection.maxSelectedTaxRateIndex]
|
||||
floorTax.value = option ? option.year : getMinFloorTax()
|
||||
} else {
|
||||
floorTax.value = getMinFloorTax()
|
||||
}
|
||||
}
|
||||
|
||||
compute()
|
||||
})
|
||||
|
||||
return {
|
||||
snatchablePositions,
|
||||
shortfallShares,
|
||||
floorTax,
|
||||
openPositionsAvailable,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue