fix: Get KRK: inline swap widget for local dev, Uniswap link for production (#136)
- Add `VITE_ENABLE_LOCAL_SWAP` env var to config.ts (defaults false) - Create LocalSwapWidget.vue: inline ETH→KRK swap (wrap→approve→exactInputSingle) - GetKrkView.vue: show LocalSwapWidget when VITE_ENABLE_LOCAL_SWAP=true, Uniswap link otherwise - docker-compose.yml: set VITE_ENABLE_LOCAL_SWAP=true for webapp service Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9341673a1a
commit
bce4059de9
4 changed files with 346 additions and 41 deletions
|
|
@ -131,6 +131,7 @@ services:
|
|||
environment:
|
||||
- CHOKIDAR_USEPOLLING=1
|
||||
- GIT_BRANCH=${GIT_BRANCH:-}
|
||||
- VITE_ENABLE_LOCAL_SWAP=true
|
||||
expose:
|
||||
- "5173"
|
||||
ports:
|
||||
|
|
|
|||
329
web-app/src/components/LocalSwapWidget.vue
Normal file
329
web-app/src/components/LocalSwapWidget.vue
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<template>
|
||||
<div class="local-swap-widget">
|
||||
<template v-if="!canSwap">
|
||||
<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-field">
|
||||
<label for="local-swap-amount" class="swap-label">ETH to spend</label>
|
||||
<input id="local-swap-amount" v-model="swapAmount" type="number" min="0" step="0.01" class="swap-input" :disabled="swapping" />
|
||||
</div>
|
||||
<button class="swap-button" :disabled="swapping" @click="buyKrk">
|
||||
{{ swapping ? 'Submitting…' : 'Buy KRK' }}
|
||||
</button>
|
||||
<p class="swap-hint">Wraps ETH → WETH, approves the swap router, then trades into KRK.</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAccount } from '@wagmi/vue';
|
||||
import { readContract, writeContract } from '@wagmi/core';
|
||||
import { erc20Abi, getAddress, isAddress, maxUint256, parseEther, zeroAddress, type Abi, type Address } from 'viem';
|
||||
import { useToast } from 'vue-toastification';
|
||||
import { config as wagmiConfig } from '@/wagmi';
|
||||
import { getChain, DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { useWallet } from '@/composables/useWallet';
|
||||
|
||||
const toast = useToast();
|
||||
const { address, chainId } = useAccount();
|
||||
|
||||
const swapAmount = ref('0.1');
|
||||
const swapping = ref(false);
|
||||
|
||||
const resolvedChainId = computed(() => chainId.value ?? DEFAULT_CHAIN_ID);
|
||||
const chainConfig = computed(() => getChain(resolvedChainId.value));
|
||||
const cheatConfig = computed(() => chainConfig.value?.cheats ?? null);
|
||||
|
||||
const canSwap = computed(() =>
|
||||
Boolean(address.value && cheatConfig.value?.weth && cheatConfig.value?.swapRouter && chainConfig.value?.harb)
|
||||
);
|
||||
|
||||
const WETH_ABI = [
|
||||
{
|
||||
inputs: [],
|
||||
name: 'deposit',
|
||||
outputs: [],
|
||||
stateMutability: 'payable',
|
||||
type: 'function',
|
||||
},
|
||||
] as const satisfies Abi;
|
||||
|
||||
const SWAP_ROUTER_ABI = [
|
||||
{
|
||||
inputs: [],
|
||||
name: 'factory',
|
||||
outputs: [{ internalType: 'address', name: '', type: 'address' }],
|
||||
stateMutability: 'view',
|
||||
type: 'function',
|
||||
},
|
||||
{
|
||||
inputs: [
|
||||
{
|
||||
components: [
|
||||
{ internalType: 'address', name: 'tokenIn', type: 'address' },
|
||||
{ internalType: 'address', name: 'tokenOut', type: 'address' },
|
||||
{ internalType: 'uint24', name: 'fee', type: 'uint24' },
|
||||
{ internalType: 'address', name: 'recipient', type: 'address' },
|
||||
{ internalType: 'uint256', name: 'amountIn', type: 'uint256' },
|
||||
{ internalType: 'uint256', name: 'amountOutMinimum', type: 'uint256' },
|
||||
{ internalType: 'uint160', name: 'sqrtPriceLimitX96', type: 'uint160' },
|
||||
],
|
||||
internalType: 'struct ISwapRouter.ExactInputSingleParams',
|
||||
name: 'params',
|
||||
type: 'tuple',
|
||||
},
|
||||
],
|
||||
name: 'exactInputSingle',
|
||||
outputs: [{ internalType: 'uint256', name: 'amountOut', type: 'uint256' }],
|
||||
stateMutability: 'payable',
|
||||
type: 'function',
|
||||
},
|
||||
] as const satisfies Abi;
|
||||
|
||||
const UNISWAP_FACTORY_ABI = [
|
||||
{
|
||||
inputs: [
|
||||
{ internalType: 'address', name: 'tokenA', type: 'address' },
|
||||
{ internalType: 'address', name: 'tokenB', type: 'address' },
|
||||
{ internalType: 'uint24', name: 'fee', type: 'uint24' },
|
||||
],
|
||||
name: 'getPool',
|
||||
outputs: [{ internalType: 'address', name: 'pool', type: 'address' }],
|
||||
stateMutability: 'view',
|
||||
type: 'function',
|
||||
},
|
||||
] as const satisfies Abi;
|
||||
|
||||
const UNISWAP_POOL_ABI = [
|
||||
{
|
||||
inputs: [],
|
||||
name: 'liquidity',
|
||||
outputs: [{ internalType: 'uint128', name: '', type: 'uint128' }],
|
||||
stateMutability: 'view',
|
||||
type: 'function',
|
||||
},
|
||||
] as const satisfies Abi;
|
||||
|
||||
function ensureAddress(value: string, label: string): Address {
|
||||
if (!value || !isAddress(value)) {
|
||||
throw new Error(`${label} is not a valid address`);
|
||||
}
|
||||
return getAddress(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function coerceString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error) {
|
||||
const msg = coerceString(error.message);
|
||||
if (msg) return msg;
|
||||
}
|
||||
if (isRecord(error)) {
|
||||
const short = coerceString(error.shortMessage);
|
||||
if (short) return short;
|
||||
const msg = coerceString(error.message);
|
||||
if (msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function buyKrk() {
|
||||
if (!canSwap.value || swapping.value) return;
|
||||
try {
|
||||
swapping.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 = parseEther(swapAmount.value || '0');
|
||||
} catch {
|
||||
throw new Error('Enter a valid ETH amount');
|
||||
}
|
||||
if (amount <= 0n) throw new Error('Amount must be greater than zero');
|
||||
|
||||
const factoryAddress = await readContract(wagmiConfig, {
|
||||
abi: SWAP_ROUTER_ABI,
|
||||
address: router,
|
||||
functionName: 'factory',
|
||||
chainId: currentChainId,
|
||||
});
|
||||
const factory = ensureAddress(factoryAddress, 'Uniswap factory');
|
||||
|
||||
const poolAddress = await readContract(wagmiConfig, {
|
||||
abi: UNISWAP_FACTORY_ABI,
|
||||
address: factory,
|
||||
functionName: 'getPool',
|
||||
args: [weth, harb, 10_000],
|
||||
chainId: currentChainId,
|
||||
});
|
||||
if (!poolAddress || poolAddress === zeroAddress) {
|
||||
throw new Error('No KRK/WETH pool found at 1% fee; deploy and recenter first');
|
||||
}
|
||||
|
||||
const poolLiquidity = await readContract(wagmiConfig, {
|
||||
abi: UNISWAP_POOL_ABI,
|
||||
address: poolAddress,
|
||||
functionName: 'liquidity',
|
||||
chainId: currentChainId,
|
||||
});
|
||||
if (poolLiquidity === 0n) {
|
||||
throw new Error('KRK/WETH pool has zero liquidity; run recenter before swapping');
|
||||
}
|
||||
|
||||
const wethBalance = await readContract(wagmiConfig, {
|
||||
abi: erc20Abi,
|
||||
address: weth,
|
||||
functionName: 'balanceOf',
|
||||
args: [caller],
|
||||
chainId: currentChainId,
|
||||
});
|
||||
|
||||
const wrapAmount = amount > wethBalance ? amount - wethBalance : 0n;
|
||||
if (wrapAmount > 0n) {
|
||||
await writeContract(wagmiConfig, {
|
||||
abi: WETH_ABI,
|
||||
address: weth,
|
||||
functionName: 'deposit',
|
||||
value: wrapAmount,
|
||||
chainId: currentChainId,
|
||||
});
|
||||
}
|
||||
|
||||
const allowance = await readContract(wagmiConfig, {
|
||||
abi: erc20Abi,
|
||||
address: weth,
|
||||
functionName: 'allowance',
|
||||
args: [caller, router],
|
||||
chainId: currentChainId,
|
||||
});
|
||||
if (allowance < amount) {
|
||||
await writeContract(wagmiConfig, {
|
||||
abi: erc20Abi,
|
||||
address: weth,
|
||||
functionName: 'approve',
|
||||
args: [router, maxUint256],
|
||||
chainId: currentChainId,
|
||||
});
|
||||
}
|
||||
|
||||
await writeContract(wagmiConfig, {
|
||||
abi: SWAP_ROUTER_ABI,
|
||||
address: router,
|
||||
functionName: 'exactInputSingle',
|
||||
args: [
|
||||
{
|
||||
tokenIn: weth,
|
||||
tokenOut: harb,
|
||||
fee: 10_000,
|
||||
recipient: caller,
|
||||
amountIn: amount,
|
||||
amountOutMinimum: 0n,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
},
|
||||
],
|
||||
chainId: currentChainId,
|
||||
});
|
||||
|
||||
const { loadBalance } = useWallet();
|
||||
await loadBalance();
|
||||
|
||||
toast.success('Swap submitted. Watch your wallet for KRK.');
|
||||
} catch (error: unknown) {
|
||||
toast.error(getErrorMessage(error, 'Swap failed'));
|
||||
} finally {
|
||||
swapping.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.local-swap-widget
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
|
||||
.swap-warning
|
||||
margin: 0
|
||||
color: #FFB347
|
||||
font-size: 14px
|
||||
|
||||
.swap-label
|
||||
display: block
|
||||
font-size: 13px
|
||||
color: #a3a3a3
|
||||
margin-bottom: 6px
|
||||
|
||||
.swap-field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.swap-input
|
||||
background: #111111
|
||||
border: 1px solid #3a3a3a
|
||||
border-radius: 8px
|
||||
color: #ffffff
|
||||
font-size: 16px
|
||||
padding: 12px 16px
|
||||
outline: none
|
||||
transition: border-color 0.2s
|
||||
|
||||
&:focus
|
||||
border-color: #60a5fa
|
||||
|
||||
&:disabled
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
|
||||
.swap-button
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%)
|
||||
color: #ffffff
|
||||
font-size: 16px
|
||||
font-weight: 600
|
||||
padding: 16px 32px
|
||||
border-radius: 12px
|
||||
border: none
|
||||
cursor: pointer
|
||||
transition: all 0.3s ease
|
||||
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.3)
|
||||
|
||||
&:hover:not(:disabled)
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 6px 16px rgba(96, 165, 250, 0.4)
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)
|
||||
|
||||
&:disabled
|
||||
opacity: 0.6
|
||||
cursor: not-allowed
|
||||
transform: none
|
||||
|
||||
.swap-hint
|
||||
margin: 0
|
||||
font-size: 12px
|
||||
color: #a3a3a3
|
||||
</style>
|
||||
|
|
@ -122,3 +122,5 @@ export const chainsData = [
|
|||
export function getChain(id: number) {
|
||||
return chainsData.find(obj => obj.id === id);
|
||||
}
|
||||
|
||||
export const enableLocalSwap = env.VITE_ENABLE_LOCAL_SWAP === 'true';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,16 @@
|
|||
<div class="balance-amount">{{ formattedBalance }} $KRK</div>
|
||||
</div>
|
||||
|
||||
<div class="swap-card">
|
||||
<!-- Local dev: inline swap widget -->
|
||||
<div v-if="enableLocalSwap" class="swap-card">
|
||||
<div class="swap-explanation">
|
||||
<p>Swap ETH for $KRK directly against the local Uniswap pool.</p>
|
||||
</div>
|
||||
<LocalSwapWidget />
|
||||
</div>
|
||||
|
||||
<!-- Production: link to Uniswap -->
|
||||
<div v-else class="swap-card">
|
||||
<div class="swap-explanation">
|
||||
<p>Swap ETH for $KRK on Uniswap, then stake your position to earn rewards.</p>
|
||||
</div>
|
||||
|
|
@ -31,22 +40,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local/testnet: link to cheats page for swapping -->
|
||||
<div v-if="isLocalChain" class="local-swap-card">
|
||||
<h3>🔧 Local Development</h3>
|
||||
<p>You're on a local chain. Use the Cheat Console to swap ETH for KRK directly.</p>
|
||||
<router-link to="/cheats" class="swap-button local">
|
||||
<span>Open Cheat Console</span>
|
||||
<span class="swap-button-arrow">→</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h3>Next Steps</h3>
|
||||
<ol>
|
||||
<li v-if="isLocalChain">Use the Cheat Console to swap ETH for $KRK</li>
|
||||
<li v-if="enableLocalSwap">Swap ETH for $KRK using the widget above</li>
|
||||
<li v-else>Click the button above to open Uniswap</li>
|
||||
<li>Swap ETH for $KRK tokens</li>
|
||||
<li v-if="!enableLocalSwap">Swap ETH for $KRK tokens</li>
|
||||
<li>Return here and navigate to the Stake page</li>
|
||||
<li>Stake your $KRK to start earning</li>
|
||||
</ol>
|
||||
|
|
@ -58,7 +57,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useWallet } from '@/composables/useWallet';
|
||||
import { DEFAULT_CHAIN_ID, getChain } from '@/config';
|
||||
import { DEFAULT_CHAIN_ID, enableLocalSwap, getChain } from '@/config';
|
||||
import LocalSwapWidget from '@/components/LocalSwapWidget.vue';
|
||||
|
||||
const wallet = useWallet();
|
||||
|
||||
|
|
@ -90,8 +90,6 @@ const networkName = computed(() => {
|
|||
}
|
||||
});
|
||||
|
||||
const isLocalChain = computed(() => currentChainId.value === 31337);
|
||||
|
||||
// Build Uniswap URL with proper chain and token
|
||||
const uniswapUrl = computed(() => {
|
||||
const chainId = currentChainId.value;
|
||||
|
|
@ -224,31 +222,6 @@ const uniswapUrl = computed(() => {
|
|||
color: #ffffff
|
||||
font-weight: 500
|
||||
|
||||
.local-swap-card
|
||||
background: linear-gradient(135deg, rgba(117, 80, 174, 0.15) 0%, rgba(117, 80, 174, 0.05) 100%)
|
||||
border: 1px solid rgba(117, 80, 174, 0.3)
|
||||
border-radius: 16px
|
||||
padding: 32px
|
||||
text-align: center
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
|
||||
h3
|
||||
margin: 0
|
||||
color: #ffffff
|
||||
|
||||
p
|
||||
margin: 0
|
||||
color: #9A9898
|
||||
|
||||
.swap-button.local
|
||||
background: linear-gradient(135deg, #7550AE 0%, #5a3d8a 100%)
|
||||
box-shadow: 0 4px 12px rgba(117, 80, 174, 0.3)
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(135deg, #5a3d8a 0%, #4a2d7a 100%)
|
||||
|
||||
.next-steps
|
||||
background: #1a1a1a
|
||||
border: 1px solid #3a3a3a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue