feat: shared @harb/web3 package + landing wallet connect (#157) (#159)

This commit is contained in:
johba 2026-02-19 20:18:27 +01:00
parent 66106077ba
commit db3633425a
17 changed files with 18548 additions and 199 deletions

View file

@ -58,16 +58,20 @@ steps:
npm config set fund false npm config set fund false
npm config set audit false npm config set audit false
./scripts/build-kraiken-lib.sh ./scripts/build-kraiken-lib.sh
npm install --prefix landing --no-audit --no-fund # Root install links workspace packages (@harb/web3) + all workspace members
npm install --no-audit --no-fund
# Landing (workspace member — deps already installed by root)
npm run lint --prefix landing npm run lint --prefix landing
npm run build --prefix landing npm run build --prefix landing
npm install --prefix web-app --no-audit --no-fund # Web-app (workspace member)
npm run lint --prefix web-app npm run lint --prefix web-app
npm run test --prefix web-app -- --run npm run test --prefix web-app -- --run
npm run build --prefix web-app npm run build --prefix web-app
# Ponder (standalone — not a workspace member)
npm install --prefix services/ponder --no-audit --no-fund npm install --prefix services/ponder --no-audit --no-fund
npm run lint --prefix services/ponder npm run lint --prefix services/ponder
npm run build --prefix services/ponder npm run build --prefix services/ponder
# TxnBot (standalone)
npm install --prefix services/txnBot --no-audit --no-fund npm install --prefix services/txnBot --no-audit --no-fund
npm run lint --prefix services/txnBot npm run lint --prefix services/txnBot
npm run test --prefix services/txnBot npm run test --prefix services/txnBot

View file

@ -173,6 +173,20 @@ services:
echo "webapp/src updated from workspace" echo "webapp/src updated from workspace"
fi fi
# Overlay @harb/web3 shared package from workspace
if [ -d "$WS/packages/web3" ]; then
mkdir -p /app/packages/web3
cp -r "$WS/packages/web3/." /app/packages/web3/
# Link @harb/web3 into web-app node_modules
mkdir -p /app/web-app/node_modules/@harb
ln -sf /app/packages/web3 /app/web-app/node_modules/@harb/web3
# Symlink wagmi/viem into packages dir so @harb/web3 can resolve them
mkdir -p /app/packages/web3/node_modules
ln -sf /app/web-app/node_modules/@wagmi /app/packages/web3/node_modules/@wagmi
ln -sf /app/web-app/node_modules/viem /app/packages/web3/node_modules/viem
echo "@harb/web3 linked with wagmi/viem deps"
fi
echo "=== Starting webapp (pre-built image + source overlay) ===" echo "=== Starting webapp (pre-built image + source overlay) ==="
cd /app/web-app cd /app/web-app
# Explicitly set CI=true to disable Vue DevTools in vite.config.ts # Explicitly set CI=true to disable Vue DevTools in vite.config.ts
@ -194,6 +208,23 @@ services:
echo "landing/src updated from workspace" echo "landing/src updated from workspace"
fi fi
# Overlay @harb/web3 shared package
if [ -d "$WS/packages/web3" ]; then
mkdir -p /app/packages/web3
cp -r "$WS/packages/web3/." /app/packages/web3/
# Landing CI image doesn't have wagmi — install it
cd /app/landing
npm install --no-audit --no-fund @wagmi/vue viem 2>/dev/null || true
# Link @harb/web3
mkdir -p /app/landing/node_modules/@harb
ln -sf /app/packages/web3 /app/landing/node_modules/@harb/web3
# Symlink wagmi/viem into packages dir for resolution
mkdir -p /app/packages/web3/node_modules
ln -sf /app/landing/node_modules/@wagmi /app/packages/web3/node_modules/@wagmi 2>/dev/null || true
ln -sf /app/landing/node_modules/viem /app/packages/web3/node_modules/viem 2>/dev/null || true
echo "@harb/web3 linked for landing"
fi
echo "=== Starting landing (pre-built image + source overlay) ===" echo "=== Starting landing (pre-built image + source overlay) ==="
cd /app/landing cd /app/landing
exec npm run dev -- --host 0.0.0.0 --port 5174 exec npm run dev -- --host 0.0.0.0 --port 5174

View file

@ -19,7 +19,10 @@
"dependencies": { "dependencies": {
"sass": "^1.83.4", "sass": "^1.83.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0",
"@harb/web3": "*",
"@wagmi/vue": "^0.2.8",
"viem": "^2.22.13"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.0", "@tsconfig/node22": "^22.0.0",

View file

@ -16,6 +16,7 @@
<SocialButton type="telegram" href="https://t.me/kraikenportal"></SocialButton> <SocialButton type="telegram" href="https://t.me/kraikenportal"></SocialButton>
<SocialButton type="twitter" href="https://x.com/KrAIkenProtocol"></SocialButton> <SocialButton type="twitter" href="https://x.com/KrAIkenProtocol"></SocialButton>
</div> </div>
<WalletButton />
</div> </div>
<div class="menu-trigger" @click.stop="toggleMenu"> <div class="menu-trigger" @click.stop="toggleMenu">
<svg width="24" height="24" viewBox="0 0 24 24"> <svg width="24" height="24" viewBox="0 0 24 24">
@ -42,6 +43,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router'; import { RouterLink, useRouter } from 'vue-router';
import SocialButton from './SocialButton.vue'; import SocialButton from './SocialButton.vue';
import WalletButton from './WalletButton.vue';
import { onMounted, onUnmounted, ref, computed } from 'vue'; import { onMounted, onUnmounted, ref, computed } from 'vue';
const router = useRouter(); const router = useRouter();

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
import { useAccount, useConnect, useDisconnect } from '@harb/web3';
import { ref } from 'vue';
const { address, isConnected } = useAccount();
const { connectors, connect } = useConnect();
const { disconnect } = useDisconnect();
const showConnectors = ref(false);
function shortAddress(addr: string) {
return `${addr.slice(0, 6)}${addr.slice(-4)}`;
}
function handleConnect(connector: (typeof connectors)[number]) {
connect({ connector });
showConnectors.value = false;
}
</script>
<template>
<div class="wallet-button">
<template v-if="isConnected && address">
<button class="wallet-button__connected" @click="disconnect()">
{{ shortAddress(address) }}
</button>
</template>
<template v-else>
<button class="wallet-button__connect" @click="showConnectors = !showConnectors">
Connect Wallet
</button>
<div v-if="showConnectors" class="wallet-button__dropdown">
<button
v-for="connector in connectors"
:key="connector.uid"
class="wallet-button__option"
@click="handleConnect(connector)"
>
{{ connector.name }}
</button>
</div>
</template>
</div>
</template>
<style lang="sass" scoped>
.wallet-button
position: relative
&__connect, &__connected
padding: 0.5rem 1rem
border-radius: 8px
border: 1px solid rgba(255, 255, 255, 0.2)
background: rgba(255, 255, 255, 0.08)
color: #fff
cursor: pointer
font-size: 0.9rem
transition: background 0.2s
&:hover
background: rgba(255, 255, 255, 0.15)
&__connect
background: #3b82f6
border-color: #3b82f6
&:hover
background: #2563eb
&__dropdown
position: absolute
top: 100%
right: 0
margin-top: 0.5rem
background: #1a1a2e
border: 1px solid rgba(255, 255, 255, 0.15)
border-radius: 8px
padding: 0.5rem
min-width: 200px
z-index: 100
&__option
display: block
width: 100%
padding: 0.5rem 0.75rem
background: none
border: none
color: #fff
cursor: pointer
text-align: left
border-radius: 4px
font-size: 0.85rem
&:hover
background: rgba(255, 255, 255, 0.1)
</style>

View file

@ -0,0 +1,137 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { useAccount } from '@harb/web3';
import axios from 'axios';
const { address, isConnected } = useAccount();
const holder = ref<{
balance: string;
totalEthSpent: string;
totalTokensAcquired: string;
} | null>(null);
const stats = ref<{ currentPriceWei: string } | null>(null);
const loading = ref(false);
const endpoint = `${window.location.origin}/api/graphql`;
async function fetchWalletData(addr: string) {
loading.value = true;
try {
const res = await axios.post(endpoint, {
query: `{
holders(address: "${addr.toLowerCase()}") {
balance
totalEthSpent
totalTokensAcquired
}
protocolStatss(where: { id: "0x01" }) {
items { currentPriceWei }
}
}`,
});
holder.value = res.data?.data?.holders ?? null;
stats.value = res.data?.data?.protocolStatss?.items?.[0] ?? null;
} catch {
holder.value = null;
} finally {
loading.value = false;
}
}
watch([address, isConnected], () => {
if (isConnected.value && address.value) {
void fetchWalletData(address.value);
} else {
holder.value = null;
}
}, { immediate: true });
function fmt(wei: string, decimals = 4) {
const n = Number(wei) / 1e18;
return n.toLocaleString('en', { maximumFractionDigits: decimals });
}
const balanceKrk = computed(() => fmt(holder.value?.balance ?? '0', 2));
const hasPosition = computed(() => holder.value && BigInt(holder.value.balance) > 0n);
const avgCost = computed(() => {
if (!holder.value) return 0;
const spent = Number(holder.value.totalEthSpent) / 1e18;
const acquired = Number(holder.value.totalTokensAcquired) / 1e18;
return acquired > 0 ? spent / acquired : 0;
});
const currentPrice = computed(() =>
stats.value ? Number(stats.value.currentPriceWei) / 1e18 : 0,
);
const pnlPercent = computed(() => {
if (avgCost.value === 0) return 0;
return (currentPrice.value / avgCost.value - 1) * 100;
});
const pnlClass = computed(() => (pnlPercent.value >= 0 ? 'positive' : 'negative'));
const appUrl = computed(() => `/app/#/wallet/${address.value}`);
</script>
<template>
<div v-if="isConnected && hasPosition" class="wallet-card" :class="pnlClass">
<div class="wallet-card__balance">{{ balanceKrk }} KRK</div>
<div v-if="avgCost > 0" class="wallet-card__pnl">
{{ pnlPercent >= 0 ? '+' : '' }}{{ pnlPercent.toFixed(1) }}%
</div>
<a :href="appUrl" class="wallet-card__link">View Dashboard </a>
</div>
<div v-else-if="isConnected && !loading && !hasPosition" class="wallet-card wallet-card--empty">
<span>No KRK yet</span>
<a href="#get-krk" class="wallet-card__link">Get KRK </a>
</div>
</template>
<style lang="sass" scoped>
.wallet-card
display: flex
align-items: center
gap: 1rem
padding: 0.75rem 1.25rem
border-radius: 12px
background: rgba(255, 255, 255, 0.06)
border: 1px solid rgba(255, 255, 255, 0.15)
margin-top: 1rem
&.positive
border-color: rgba(16, 185, 129, 0.4)
background: rgba(16, 185, 129, 0.06)
&.negative
border-color: rgba(239, 68, 68, 0.4)
background: rgba(239, 68, 68, 0.06)
&--empty
color: #9a9898
font-size: 0.85rem
&__balance
font-weight: 700
font-size: 1.1rem
&__pnl
font-weight: 600
.positive &
color: #10b981
.negative &
color: #ef4444
&__link
margin-left: auto
color: #3b82f6
text-decoration: none
font-size: 0.85rem
&:hover
text-decoration: underline
</style>

View file

@ -1,10 +1,14 @@
import './assets/styles/main.sass'; import './assets/styles/main.sass';
import { createApp } from 'vue'; import { createApp } from 'vue';
import { WagmiPlugin } from '@wagmi/vue';
import { createHarbConfig } from '@harb/web3';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
const rpcUrl = import.meta.env.VITE_LOCAL_RPC_URL ?? '/api/rpc';
const config = createHarbConfig({ rpcUrl });
const app = createApp(App); const app = createApp(App);
app.use(WagmiPlugin, { config });
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');

View file

@ -14,6 +14,7 @@
</div> </div>
<div class="blur-effect"></div> <div class="blur-effect"></div>
</div> </div>
<WalletCard />
<LiveStats /> <LiveStats />
<div class="k-container"> <div class="k-container">
<section class="how-it-works-section"> <section class="how-it-works-section">
@ -88,6 +89,7 @@
import KButton from '@/components/KButton.vue'; import KButton from '@/components/KButton.vue';
import LeftRightComponent from '@/components/LeftRightComponent.vue'; import LeftRightComponent from '@/components/LeftRightComponent.vue';
import LiveStats from '@/components/LiveStats.vue'; import LiveStats from '@/components/LiveStats.vue';
import WalletCard from '@/components/WalletCard.vue';
import { useMobile } from '@/composables/useMobile'; import { useMobile } from '@/composables/useMobile';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';

18217
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,5 +9,10 @@
"prepare": "husky", "prepare": "husky",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"type": "module" "type": "module",
"workspaces": [
"packages/*",
"landing",
"web-app"
]
} }

View file

@ -0,0 +1,15 @@
{
"name": "@harb/web3",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@wagmi/vue": "^0.2.8",
"viem": "^2.22.13"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}

View file

@ -0,0 +1,59 @@
import { ref, watch, type Ref } from 'vue';
import { useAccount, useChainId } from '@wagmi/vue';
import { createPublicClient, erc20Abi, formatUnits, http } from 'viem';
import { getHarbConfig, KRAIKEN_LOCAL_CHAIN } from '../config';
/**
* Read-only KRK token balance for the connected wallet.
* Lightweight no contract writes, no staking, just balanceOf.
*/
export function useTokenBalance(tokenAddress: Ref<`0x${string}` | undefined> | `0x${string}`) {
const { address, isConnected } = useAccount();
const chainId = useChainId();
const balance = ref(0n);
const formatted = ref('0');
const loading = ref(false);
async function refresh() {
const token = typeof tokenAddress === 'string' ? tokenAddress : tokenAddress.value;
if (!address.value || !token) {
balance.value = 0n;
formatted.value = '0';
return;
}
loading.value = true;
try {
const config = getHarbConfig();
const chain = config.chains.find(c => c.id === chainId.value) ?? KRAIKEN_LOCAL_CHAIN;
const rpcUrl = chain.rpcUrls.default.http[0];
const client = createPublicClient({ chain, transport: http(rpcUrl) });
const result = await client.readContract({
abi: erc20Abi,
address: token,
functionName: 'balanceOf',
args: [address.value],
});
balance.value = result;
formatted.value = formatUnits(result, 18);
} catch {
balance.value = 0n;
formatted.value = '0';
} finally {
loading.value = false;
}
}
watch([address, isConnected], () => {
if (isConnected.value) {
void refresh();
} else {
balance.value = 0n;
formatted.value = '0';
}
}, { immediate: true });
return { balance, formatted, loading, refresh };
}

View file

@ -0,0 +1,87 @@
import { http, createConfig, createStorage } from '@wagmi/vue';
import { baseSepolia } from '@wagmi/vue/chains';
import { coinbaseWallet, injected, walletConnect } from '@wagmi/vue/connectors';
import { defineChain } from 'viem';
/**
* Shared wagmi config for all harb apps.
* RPC URL and WalletConnect project ID are passed in to keep this package env-agnostic.
*/
export interface HarbWeb3Options {
rpcUrl?: string;
walletConnectProjectId?: string;
}
const defaults = {
rpcUrl: '/api/rpc',
walletConnectProjectId: 'd8e5ecb0353c02e21d4c0867d4473ac5',
};
export const KRAIKEN_LOCAL_CHAIN = defineChain({
id: 31337,
name: 'Kraiken Local Fork',
network: 'kraiken-local',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: {
default: { http: ['http://localhost:8545'] },
public: { http: ['http://localhost:8545'] },
},
blockExplorers: {
default: { name: 'Local Explorer', url: '' },
},
testnet: true,
});
let _config: ReturnType<typeof createConfig> | null = null;
export function createHarbConfig(opts: HarbWeb3Options = {}) {
const rpcUrl = opts.rpcUrl ?? defaults.rpcUrl;
const wcProjectId = opts.walletConnectProjectId ?? defaults.walletConnectProjectId;
// Build chain with provided RPC URL
const chain = defineChain({
...KRAIKEN_LOCAL_CHAIN,
rpcUrls: {
default: { http: [rpcUrl] },
public: { http: [rpcUrl] },
},
});
_config = createConfig({
chains: [chain, baseSepolia],
storage: createStorage({ storage: window.localStorage }),
connectors: [
injected(),
walletConnect({
projectId: wcProjectId,
metadata: {
name: 'Kraiken',
description: 'Connect your wallet with Kraiken',
url: 'https://kraiken.eth.limo',
icons: [''],
},
}),
coinbaseWallet({
appName: 'Kraiken',
darkMode: true,
preference: { options: 'all', telemetry: false },
}),
],
transports: {
[chain.id]: http(rpcUrl),
[baseSepolia.id]: http(),
},
});
// Default to local chain
if (_config.state.chainId !== chain.id) {
_config.setState(state => ({ ...state, chainId: chain.id }));
}
return _config;
}
export function getHarbConfig() {
if (!_config) throw new Error('@harb/web3: call createHarbConfig() first');
return _config;
}

View file

@ -0,0 +1,6 @@
export { createHarbConfig, getHarbConfig, KRAIKEN_LOCAL_CHAIN } from './config';
export type { HarbWeb3Options } from './config';
export { useTokenBalance } from './composables/useTokenBalance';
// Re-export commonly used wagmi composables so consumers don't need to import @wagmi/vue directly
export { useAccount, useConnect, useDisconnect, useChainId } from '@wagmi/vue';

View file

@ -16,8 +16,9 @@ export default [
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
parserOptions: { parserOptions: {
projectService: true, projectService: {
project: [resolve(__dirname, 'tsconfig.app.json')], defaultProject: resolve(__dirname, 'tsconfig.app.json'),
},
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
sourceType: 'module', sourceType: 'module',
ecmaVersion: 'latest', ecmaVersion: 'latest',

View file

@ -32,7 +32,8 @@
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"vue-tippy": "^6.6.0", "vue-tippy": "^6.6.0",
"vue-toastification": "^2.0.0-rc.5" "vue-toastification": "^2.0.0-rc.5",
"@harb/web3": "*"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^4.3.0", "@iconify/vue": "^4.3.0",

View file

@ -1,56 +1,10 @@
import { http, createConfig, createStorage } from '@wagmi/vue'; /**
import { baseSepolia } from '@wagmi/vue/chains'; * Web-app wagmi config delegates to @harb/web3 shared package.
import { coinbaseWallet, injected, walletConnect } from '@wagmi/vue/connectors'; * Re-exports for backward compatibility with existing imports.
import { defineChain } from 'viem'; */
import { createHarbConfig, KRAIKEN_LOCAL_CHAIN } from '@harb/web3';
const LOCAL_RPC_URL = import.meta.env.VITE_LOCAL_RPC_URL ?? '/api/rpc'; const LOCAL_RPC_URL = import.meta.env.VITE_LOCAL_RPC_URL ?? '/api/rpc';
export const KRAIKEN_LOCAL_CHAIN = defineChain({ export { KRAIKEN_LOCAL_CHAIN };
id: 31337, export const config = createHarbConfig({ rpcUrl: LOCAL_RPC_URL });
name: 'Kraiken Local Fork',
network: 'kraiken-local',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: {
default: { http: [LOCAL_RPC_URL] },
public: { http: [LOCAL_RPC_URL] },
},
blockExplorers: {
default: { name: 'Local Explorer', url: '' },
},
testnet: true,
});
export const config = createConfig({
chains: [KRAIKEN_LOCAL_CHAIN, baseSepolia],
storage: createStorage({ storage: window.localStorage }),
connectors: [
// Injected wallets (MetaMask, Brave, etc.) - also supports E2E test wallet mocks
injected(),
walletConnect({
projectId: 'd8e5ecb0353c02e21d4c0867d4473ac5',
metadata: {
name: 'Kraiken',
description: 'Connect your wallet with Kraiken',
url: 'https://kraiken.eth.limo',
icons: [''],
},
}),
coinbaseWallet({
appName: 'Kraiken',
darkMode: true,
preference: {
options: 'all',
telemetry: false,
},
}),
],
transports: {
[KRAIKEN_LOCAL_CHAIN.id]: http(LOCAL_RPC_URL),
[baseSepolia.id]: http(),
},
});
if (typeof window !== 'undefined' && config.state.chainId !== KRAIKEN_LOCAL_CHAIN.id) {
config.setState(state => ({ ...state, chainId: KRAIKEN_LOCAL_CHAIN.id }));
}