fix: feat: Add sell KRK widget to get-krk page (#456)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-05 12:03:36 +00:00
parent 466e0d7767
commit 36c798605f
2 changed files with 245 additions and 10 deletions

View file

@ -4,6 +4,22 @@
<p class="swap-warning">Connect your wallet to swap ETH for $KRK on the local Uniswap pool.</p>
</template>
<template v-else>
<div class="swap-tabs">
<button
class="swap-tab"
:class="{ active: mode === 'buy' }"
data-testid="swap-mode-buy"
@click="setMode('buy')"
>Buy</button>
<button
class="swap-tab"
:class="{ active: mode === 'sell' }"
data-testid="swap-mode-sell"
@click="setMode('sell')"
>Sell</button>
</div>
<template v-if="mode === 'buy'">
<div class="swap-field">
<label for="local-swap-amount" class="swap-label">ETH to spend</label>
<input id="local-swap-amount" data-testid="swap-amount-input" :value="swapAmount" @input="swapAmount = ($event.target as HTMLInputElement).value" type="number" min="0" step="0.01" class="swap-input" :disabled="swapping" />
@ -13,13 +29,53 @@
</button>
<p class="swap-hint">Wraps ETH WETH, approves the swap router, then trades into $KRK.</p>
</template>
<template v-else>
<div class="swap-field">
<div class="swap-label-row">
<label for="local-sell-amount" class="swap-label">KRK to sell</label>
<span class="swap-balance">Balance: {{ formattedKrkBalance }} KRK</span>
</div>
<div class="swap-input-row">
<input id="local-sell-amount" data-testid="swap-sell-amount-input" :value="sellAmount" @input="sellAmount = ($event.target as HTMLInputElement).value" type="number" min="0" step="1" class="swap-input" :disabled="selling" />
<button class="max-button" :disabled="selling" @click="setMax">Max</button>
</div>
</div>
<button class="swap-button sell-button" data-testid="swap-sell-button" :disabled="selling" @click="sellKrk">
{{ selling ? (sellPhase === 'approving' ? 'Approving…' : 'Selling…') : 'Sell KRK' }}
</button>
<p class="swap-hint">Approves the swap router, then trades $KRK WETH.</p>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useSwapKrk } from '@/composables/useSwapKrk';
import { formatUnits } from 'viem';
const { swapAmount, swapping, canSwap, buyKrk } = useSwapKrk();
const { swapAmount, swapping, canSwap, buyKrk, sellAmount, selling, sellPhase, krkBalance, loadKrkBalance, sellKrk } = useSwapKrk();
const mode = ref<'buy' | 'sell'>('buy');
const formattedKrkBalance = computed(() => {
const val = Number(formatUnits(krkBalance.value, 18));
return val.toLocaleString(undefined, { maximumFractionDigits: 4 });
});
function setMode(m: 'buy' | 'sell') {
mode.value = m;
if (m === 'sell') loadKrkBalance();
}
function setMax() {
sellAmount.value = formatUnits(krkBalance.value, 18);
}
onMounted(() => {
if (canSwap.value) loadKrkBalance();
});
</script>
<style lang="sass" scoped>
@ -33,16 +89,55 @@ const { swapAmount, swapping, canSwap, buyKrk } = useSwapKrk();
color: #FFB347
font-size: 14px
.swap-tabs
display: flex
gap: 8px
.swap-tab
flex: 1
padding: 8px 0
border-radius: 8px
border: 1px solid #3a3a3a
background: #111111
color: #a3a3a3
font-size: 14px
font-weight: 600
cursor: pointer
transition: all 0.2s
&.active
background: #1e3a5f
border-color: #60a5fa
color: #ffffff
&:hover:not(.active)
border-color: #60a5fa
color: #ffffff
.swap-label
display: block
font-size: 13px
color: #a3a3a3
margin-bottom: 6px
.swap-label-row
display: flex
justify-content: space-between
align-items: baseline
margin-bottom: 6px
.swap-balance
font-size: 12px
color: #a3a3a3
.swap-field
display: flex
flex-direction: column
.swap-input-row
display: flex
gap: 8px
.swap-input
background: #111111
border: 1px solid #3a3a3a
@ -52,6 +147,8 @@ const { swapAmount, swapping, canSwap, buyKrk } = useSwapKrk();
padding: 12px 16px
outline: none
transition: border-color 0.2s
flex: 1
min-width: 0
&:focus
border-color: #60a5fa
@ -60,6 +157,26 @@ const { swapAmount, swapping, canSwap, buyKrk } = useSwapKrk();
opacity: 0.5
cursor: not-allowed
.max-button
padding: 0 16px
border-radius: 8px
border: 1px solid #3a3a3a
background: #1a1a1a
color: #60a5fa
font-size: 13px
font-weight: 600
cursor: pointer
transition: all 0.2s
white-space: nowrap
&:hover:not(:disabled)
border-color: #60a5fa
background: #1e3a5f
&:disabled
opacity: 0.5
cursor: not-allowed
.swap-button
display: flex
align-items: center
@ -85,6 +202,14 @@ const { swapAmount, swapping, canSwap, buyKrk } = useSwapKrk();
cursor: not-allowed
transform: none
&.sell-button
background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%)
box-shadow: 0 4px 12px rgba(244, 114, 182, 0.3)
&:hover:not(:disabled)
box-shadow: 0 6px 16px rgba(244, 114, 182, 0.4)
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%)
.swap-hint
margin: 0
font-size: 12px

View file

@ -1,7 +1,7 @@
import { computed, ref } from 'vue';
import { useAccount } from '@wagmi/vue';
import { readContract, writeContract } from '@wagmi/core';
import { erc20Abi, maxUint256, parseEther, zeroAddress, type Abi } from 'viem';
import { erc20Abi, maxUint256, parseEther, parseUnits, zeroAddress, type Abi } from 'viem';
import { useToast } from 'vue-toastification';
import { config as wagmiConfig } from '@/wagmi';
import { getChain, DEFAULT_CHAIN_ID } from '@/config';
@ -16,6 +16,13 @@ export const WETH_ABI = [
stateMutability: 'payable',
type: 'function',
},
{
inputs: [{ internalType: 'uint256', name: 'wad', type: 'uint256' }],
name: 'withdraw',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
] as const satisfies Abi;
export const SWAP_ROUTER_ABI = [
@ -97,6 +104,10 @@ export function useSwapKrk() {
const swapAmount = ref('0.1');
const swapping = ref(false);
const sellAmount = ref('');
const selling = ref(false);
const sellPhase = ref<'approving' | 'selling'>('approving');
const krkBalance = ref<bigint>(0n);
const resolvedChainId = computed(() => chainId.value ?? DEFAULT_CHAIN_ID);
const chainConfig = computed(() => getChain(resolvedChainId.value));
@ -106,6 +117,23 @@ export function useSwapKrk() {
Boolean(address.value && cheatConfig.value?.weth && cheatConfig.value?.swapRouter && chainConfig.value?.harb)
);
async function loadKrkBalance() {
if (!address.value || !chainConfig.value?.harb) return;
try {
const harb = ensureAddress(chainConfig.value.harb, 'KRAIKEN token');
const caller = ensureAddress(address.value, 'Wallet address');
krkBalance.value = await readContract(wagmiConfig, {
abi: erc20Abi,
address: harb,
functionName: 'balanceOf',
args: [caller],
chainId: resolvedChainId.value,
});
} catch {
krkBalance.value = 0n;
}
}
async function buyKrk() {
if (!canSwap.value || swapping.value) return;
try {
@ -224,5 +252,87 @@ export function useSwapKrk() {
}
}
return { swapAmount, swapping, canSwap, buyKrk };
async function sellKrk() {
if (!canSwap.value || selling.value) return;
try {
selling.value = true;
const chain = chainConfig.value;
const cheat = cheatConfig.value;
if (!chain?.harb) throw new Error('No Kraiken deployment configured for this chain');
if (!cheat) throw new Error('Swap helpers are not configured for this chain');
const weth = ensureAddress(cheat.weth, 'WETH');
const router = ensureAddress(cheat.swapRouter, 'Swap router');
const harb = ensureAddress(chain.harb, 'KRAIKEN token');
const caller = ensureAddress(address.value!, 'Wallet address');
const currentChainId = resolvedChainId.value;
let amount: bigint;
try {
amount = parseUnits(sellAmount.value || '0', 18);
} catch {
throw new Error('Enter a valid KRK amount');
}
if (amount <= 0n) throw new Error('Amount must be greater than zero');
const balance = await readContract(wagmiConfig, {
abi: erc20Abi,
address: harb,
functionName: 'balanceOf',
args: [caller],
chainId: currentChainId,
});
if (amount > balance) throw new Error('Insufficient KRK balance');
sellPhase.value = 'approving';
const allowance = await readContract(wagmiConfig, {
abi: erc20Abi,
address: harb,
functionName: 'allowance',
args: [caller, router],
chainId: currentChainId,
});
if (allowance < amount) {
await writeContract(wagmiConfig, {
abi: erc20Abi,
address: harb,
functionName: 'approve',
args: [router, maxUint256],
chainId: currentChainId,
});
}
sellPhase.value = 'selling';
await writeContract(wagmiConfig, {
abi: SWAP_ROUTER_ABI,
address: router,
functionName: 'exactInputSingle',
args: [
{
tokenIn: harb,
tokenOut: weth,
fee: 10_000,
recipient: caller,
amountIn: amount,
amountOutMinimum: 0n,
sqrtPriceLimitX96: 0n,
},
],
chainId: currentChainId,
});
await loadBalance();
await loadKrkBalance();
toast.success('Sell complete. WETH has been added to your wallet.');
} catch (error: unknown) {
toast.error(getErrorMessage(error, 'Sell failed'));
} finally {
selling.value = false;
}
}
return { swapAmount, swapping, canSwap, buyKrk, sellAmount, selling, sellPhase, krkBalance, loadKrkBalance, sellKrk };
}