No KRK yet
diff --git a/landing/src/router/index.ts b/landing/src/router/index.ts
index 7d65c76..5e52a93 100644
--- a/landing/src/router/index.ts
+++ b/landing/src/router/index.ts
@@ -20,6 +20,11 @@ const router = createRouter({
name: 'mixed',
component: () => import('../views/HomeViewMixed.vue'),
},
+ {
+ path: '/wallet/:address',
+ name: 'wallet',
+ component: () => import('../views/HolderDashboardView.vue'),
+ },
{
path: '/docs',
name: 'Docs',
diff --git a/landing/src/views/HolderDashboardView.vue b/landing/src/views/HolderDashboardView.vue
new file mode 100644
index 0000000..d135f81
--- /dev/null
+++ b/landing/src/views/HolderDashboardView.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
+
+
Loading wallet data…
+
+
⚠ {{ error }}
+
+
+
+
Unrealized P&L
+
+ {{ unrealizedPnlEth >= 0 ? '+' : '−' }}{{ fmtEthUsd(Math.abs(unrealizedPnlEth)) }}
+
+
+ {{ unrealizedPnlEth >= 0 ? '+' : '−' }}{{ formatEthCompact(Math.abs(unrealizedPnlEth)) }}
+
+
+ {{ unrealizedPnlPercent >= 0 ? '+' : '' }}{{ unrealizedPnlPercent.toFixed(1) }}%
+
+
+ Avg cost: {{ fmtEthUsd(avgCostBasis) }}/KRK · Current: {{ fmtEthUsd(currentPriceEth) }}/KRK
+
+
+
+
+
+
+
KRK Balance
+
{{ formatKrk(balanceKrk) }}
+
KRK
+
+
+
ETH Backing
+
{{ fmtEthUsd(ethBacking) }}
+
{{ formatEthCompact(ethBacking) }}
+
ETH
+
+
+
+
+
+
+
+
+
+
+
diff --git a/landing/vite.config.ts b/landing/vite.config.ts
index f022561..3b96358 100644
--- a/landing/vite.config.ts
+++ b/landing/vite.config.ts
@@ -13,7 +13,8 @@ export default defineConfig({
],
resolve: {
alias: {
- '@': fileURLToPath(new URL('./src', import.meta.url))
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ '@harb/ui-shared': fileURLToPath(new URL('../packages/ui-shared/src', import.meta.url)),
},
},
server: {
diff --git a/package-lock.json b/package-lock.json
index 65899d0..e86f862 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "harb-worktree-196",
+ "name": "harb-worktree-150",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -63,6 +63,7 @@
"name": "vue-kraiken",
"version": "0.0.0",
"dependencies": {
+ "@harb/ui-shared": "*",
"@harb/web3": "*",
"@tanstack/vue-query": "^5.92.9",
"@wagmi/vue": "^0.2.8",
@@ -3524,6 +3525,10 @@
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
+ "node_modules/@harb/ui-shared": {
+ "resolved": "packages/ui-shared",
+ "link": true
+ },
"node_modules/@harb/web3": {
"resolved": "packages/web3",
"link": true
@@ -21804,6 +21809,13 @@
}
}
},
+ "packages/ui-shared": {
+ "name": "@harb/ui-shared",
+ "version": "0.1.0",
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
"packages/web3": {
"name": "@harb/web3",
"version": "0.1.0",
@@ -21819,6 +21831,7 @@
"name": "harb-staking",
"version": "0.0.0",
"dependencies": {
+ "@harb/ui-shared": "*",
"@harb/web3": "*",
"@tanstack/vue-query": "^5.64.2",
"@vue/test-utils": "^2.4.6",
diff --git a/packages/ui-shared/package.json b/packages/ui-shared/package.json
new file mode 100644
index 0000000..40bf4d5
--- /dev/null
+++ b/packages/ui-shared/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@harb/ui-shared",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+}
diff --git a/packages/ui-shared/src/components/TransactionHistory.vue b/packages/ui-shared/src/components/TransactionHistory.vue
new file mode 100644
index 0000000..c7e42b7
--- /dev/null
+++ b/packages/ui-shared/src/components/TransactionHistory.vue
@@ -0,0 +1,381 @@
+
+
+
+ Transaction History
+ {{ transactions.length }}
+
+
+
+
+ Loading transactions…
+
+
+
⚠ {{ error }}
+
+
No transactions found for this address.
+
+
+
+
+
+ | Date |
+ Type |
+ Amount (KRK) |
+ Value |
+ Tx |
+
+
+
+
+ | {{ formatDate(tx.timestamp) }} |
+
+
+ {{ txTypeLabel(tx.type) }}
+
+ |
+ {{ formatKrk(tx.tokenAmount) }} |
+
+
+
+ {{ ethUsdPrice ? formatCellUsd(tx.ethAmount) : formatEthCell(tx.ethAmount) }}
+
+
+ {{ formatEthCell(tx.ethAmount) }}
+
+ —
+ |
+
+
+ {{ shortHash(tx.txHash) }} ↗
+
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui-shared/src/composables/useEthPrice.ts b/packages/ui-shared/src/composables/useEthPrice.ts
new file mode 100644
index 0000000..eebadc7
--- /dev/null
+++ b/packages/ui-shared/src/composables/useEthPrice.ts
@@ -0,0 +1,65 @@
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const ETH_PRICE_CACHE_MS = 5 * 60 * 1000; // 5 minutes
+
+// Module-level cache shared across all composable instances on the same page
+let _cachedPrice: number | null = null;
+let _cacheTime = 0;
+
+export function formatUsd(usd: number): string {
+ if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}k`;
+ if (usd >= 1) return `$${usd.toFixed(2)}`;
+ if (usd >= 0.01) return `$${usd.toFixed(3)}`;
+ return `$${usd.toFixed(4)}`;
+}
+
+export function formatEthCompact(eth: number): string {
+ if (eth === 0) return '0 ETH';
+ if (eth >= 1) return `${eth.toFixed(2)} ETH`;
+ if (eth >= 0.01) return `${eth.toFixed(4)} ETH`;
+ if (eth >= 0.0001) return `${eth.toFixed(6)} ETH`;
+ return `${eth.toPrecision(4)} ETH`;
+}
+
+export function useEthPrice() {
+ const ethUsdPrice = ref
(_cachedPrice);
+
+ async function fetchEthPrice() {
+ const now = Date.now();
+ if (_cachedPrice !== null && now - _cacheTime < ETH_PRICE_CACHE_MS) {
+ ethUsdPrice.value = _cachedPrice;
+ return;
+ }
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 5000);
+ const resp = await fetch(
+ 'https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd',
+ { signal: controller.signal },
+ );
+ clearTimeout(timeout);
+ if (!resp.ok) throw new Error('ETH price fetch failed');
+ const data = await resp.json();
+ if (data.ethereum?.usd) {
+ _cachedPrice = data.ethereum.usd;
+ _cacheTime = now;
+ ethUsdPrice.value = _cachedPrice;
+ }
+ } catch {
+ // Keep existing cached price or null; ETH fallback will be used
+ }
+ }
+
+ let interval: ReturnType | null = null;
+
+ onMounted(async () => {
+ await fetchEthPrice();
+ interval = setInterval(() => void fetchEthPrice(), ETH_PRICE_CACHE_MS);
+ });
+
+ onUnmounted(() => {
+ if (interval) clearInterval(interval);
+ });
+
+ return { ethUsdPrice, fetchEthPrice };
+}
diff --git a/packages/ui-shared/src/composables/useHolderDashboard.ts b/packages/ui-shared/src/composables/useHolderDashboard.ts
new file mode 100644
index 0000000..f37031b
--- /dev/null
+++ b/packages/ui-shared/src/composables/useHolderDashboard.ts
@@ -0,0 +1,140 @@
+import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
+
+const POLL_INTERVAL_MS = 30_000;
+
+function formatTokenAmount(rawWei: string, decimals = 18): number {
+ try {
+ const big = BigInt(rawWei);
+ // Use BigInt arithmetic to avoid float64 precision loss at high values
+ return Number(big * 10000n / (10n ** BigInt(decimals))) / 10000;
+ } catch {
+ return 0;
+ }
+}
+
+export function useHolderDashboard(address: Ref, graphqlUrl: string | Ref = '/api/graphql') {
+ const holderBalance = ref('0');
+ const holderTotalEthSpent = ref('0');
+ const holderTotalTokensAcquired = ref('0');
+ const currentPriceWei = ref(null);
+ const lastEthReserve = ref('0');
+ const kraikenTotalSupply = ref('0');
+ const loading = ref(false);
+ const error = ref(null);
+ let pollTimer: ReturnType | null = null;
+
+ function resolveUrl(): string {
+ return typeof graphqlUrl === 'string' ? graphqlUrl : graphqlUrl.value;
+ }
+
+ async function fetchData() {
+ const addr = address.value?.toLowerCase();
+ if (!addr) return;
+
+ loading.value = true;
+ error.value = null;
+
+ try {
+ const res = await fetch(resolveUrl(), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: `query HolderDashboard {
+ holders(address: "${addr}") {
+ balance
+ totalEthSpent
+ totalTokensAcquired
+ }
+ statss(where: { id: "0x01" }) {
+ items {
+ kraikenTotalSupply
+ lastEthReserve
+ currentPriceWei
+ }
+ }
+ }`,
+ }),
+ });
+
+ const json = await res.json();
+
+ if (Array.isArray(json?.errors) && json.errors.length > 0) {
+ const msgs = json.errors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', ');
+ throw new Error(msgs);
+ }
+
+ const holder = json?.data?.holders;
+ holderBalance.value = holder?.balance ?? '0';
+ holderTotalEthSpent.value = holder?.totalEthSpent ?? '0';
+ holderTotalTokensAcquired.value = holder?.totalTokensAcquired ?? '0';
+
+ const statsItems = json?.data?.statss?.items;
+ const statsRow = Array.isArray(statsItems) && statsItems.length > 0 ? statsItems[0] : null;
+ currentPriceWei.value = statsRow?.currentPriceWei ?? null;
+ lastEthReserve.value = statsRow?.lastEthReserve ?? '0';
+ kraikenTotalSupply.value = statsRow?.kraikenTotalSupply ?? '0';
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to load holder data';
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ const balanceKrk = computed(() => formatTokenAmount(holderBalance.value));
+
+ const ethBacking = computed(() => {
+ const balance = balanceKrk.value;
+ const reserve = formatTokenAmount(lastEthReserve.value);
+ const totalSupply = formatTokenAmount(kraikenTotalSupply.value);
+ if (totalSupply === 0) return 0;
+ return balance * (reserve / totalSupply);
+ });
+
+ const avgCostBasis = computed(() => {
+ const spent = formatTokenAmount(holderTotalEthSpent.value);
+ const acquired = formatTokenAmount(holderTotalTokensAcquired.value);
+ if (acquired === 0) return 0;
+ return spent / acquired;
+ });
+
+ const currentPriceEth = computed(() => {
+ if (!currentPriceWei.value) return 0;
+ return formatTokenAmount(currentPriceWei.value);
+ });
+
+ const unrealizedPnlEth = computed(() => {
+ const basis = avgCostBasis.value;
+ if (basis === 0) return 0;
+ return (currentPriceEth.value - basis) * balanceKrk.value;
+ });
+
+ const unrealizedPnlPercent = computed(() => {
+ const basis = avgCostBasis.value;
+ if (basis === 0) return 0;
+ return (currentPriceEth.value / basis - 1) * 100;
+ });
+
+ onMounted(async () => {
+ await fetchData();
+ pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
+ });
+
+ onUnmounted(() => {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ });
+
+ return {
+ loading,
+ error,
+ balanceKrk,
+ avgCostBasis,
+ currentPriceEth,
+ unrealizedPnlEth,
+ unrealizedPnlPercent,
+ ethBacking,
+ refresh: fetchData,
+ };
+}
diff --git a/packages/ui-shared/src/index.ts b/packages/ui-shared/src/index.ts
new file mode 100644
index 0000000..5a32895
--- /dev/null
+++ b/packages/ui-shared/src/index.ts
@@ -0,0 +1,3 @@
+export { useHolderDashboard } from './composables/useHolderDashboard';
+export { useEthPrice, formatUsd, formatEthCompact } from './composables/useEthPrice';
+export { default as TransactionHistory } from './components/TransactionHistory.vue';