@meddleware/dao-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>DAO · Meddleware</title>
7
+ <meta name="description" content="Meddleware DAO console — treasury overview, proposals, and on-chain governance." />
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@meddleware/dao-ui",
3
+ "version": "0.1.0",
4
+ "description": "Meddleware DAO console — treasury overview, proposals, and governance on Sui.",
5
+ "author": "Meddleware <dev@meddleware.co.uk>",
6
+ "license": "0BSD",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/meddleware-org/dao-ui.git"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src",
14
+ "index.html",
15
+ "vite.config.ts",
16
+ "tsconfig.json",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "scripts": {
20
+ "dev": "vite",
21
+ "build": "vue-tsc --noEmit && vite build",
22
+ "preview": "vite preview",
23
+ "type-check": "vue-tsc --noEmit",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./src/index.ts",
30
+ "default": "./src/index.ts"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "sideEffects": [
35
+ "*.css",
36
+ "*.vue"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@meddleware/design-tokens": "^0.1.2",
43
+ "@meddleware/ui": "^0.1.10",
44
+ "@meddleware/wallet-adapter": "^0.0.5",
45
+ "@mysten/sui": "^2.30.0",
46
+ "vue": "^3.5.40"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "~24.12.2",
50
+ "@vitejs/plugin-vue": "^6.0.8",
51
+ "@vue/test-utils": "^2.4.6",
52
+ "typescript": "~6.0.0",
53
+ "vite": "^8.1.5",
54
+ "vitest": "~2.1.9",
55
+ "vue-tsc": "~3.3.0"
56
+ },
57
+ "engines": {
58
+ "node": "^22.18.0 || >=24.12.0"
59
+ }
60
+ }
package/src/App.vue ADDED
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ // Standalone shell for the DAO SPA. The core UI lives in DaoView.vue (also exported
3
+ // for inline embedding in the dashboard).
4
+ import { AppHeader, AppFooter, ColorModeControl, useColorMode } from '@meddleware/ui'
5
+ import { NETWORK } from './config.js'
6
+ import DaoView from './DaoView.vue'
7
+
8
+ const { mode, set } = useColorMode('dark')
9
+ </script>
10
+
11
+ <template>
12
+ <div class="app">
13
+ <AppHeader variant="dark">
14
+ <template #brand>
15
+ <h1 class="brand-title">Meddleware DAO</h1>
16
+ </template>
17
+ <template #actions>
18
+ <span class="network-badge">{{ NETWORK }}</span>
19
+ <ColorModeControl :model-value="mode" @update:model-value="set" />
20
+ </template>
21
+ </AppHeader>
22
+
23
+ <DaoView style="flex: 1; min-height: 0" />
24
+
25
+ <AppFooter />
26
+ </div>
27
+ </template>
28
+
29
+ <style scoped>
30
+ .app {
31
+ min-height: 100vh;
32
+ display: flex;
33
+ flex-direction: column;
34
+ }
35
+
36
+ .brand-title {
37
+ font: inherit;
38
+ margin: 0;
39
+ }
40
+
41
+ .network-badge {
42
+ font-size: 0.72rem;
43
+ padding: 2px 8px;
44
+ border-radius: 2px;
45
+ border: 1px solid var(--border);
46
+ color: var(--muted);
47
+ font-family: var(--font-mono);
48
+ text-transform: uppercase;
49
+ letter-spacing: 0.05em;
50
+ }
51
+ </style>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ // Core DAO tool UI — tab-based console with desktop-application aesthetics.
3
+ // Rendered standalone by App.vue and embedded in the dashboard at the default route.
4
+ // All chain reads are read-only; no wallet connection is required to view data.
5
+ import { ref, shallowRef, computed } from 'vue'
6
+ import TabBar from './components/TabBar.vue'
7
+ import StatusBar from './components/StatusBar.vue'
8
+ import OverviewTab from './tabs/OverviewTab.vue'
9
+ import TreasuryTab from './tabs/TreasuryTab.vue'
10
+ import ProposalsTab from './tabs/ProposalsTab.vue'
11
+ import GovernanceTab from './tabs/GovernanceTab.vue'
12
+ import HistoryTab from './tabs/HistoryTab.vue'
13
+ import { useDaoEvents } from './composables/useDaoEvents.js'
14
+ import { useEpoch } from './composables/useEpoch.js'
15
+
16
+ import type { Tab } from './components/TabBar.vue'
17
+
18
+ const TABS: Tab[] = [
19
+ { id: 'overview', label: 'Overview' },
20
+ { id: 'treasury', label: 'Treasury' },
21
+ { id: 'proposals', label: 'Proposals' },
22
+ { id: 'governance', label: 'Governance' },
23
+ { id: 'history', label: 'History' },
24
+ ]
25
+
26
+ const activeTab = ref('overview')
27
+
28
+ const TAB_COMPONENTS = {
29
+ overview: OverviewTab,
30
+ treasury: TreasuryTab,
31
+ proposals: ProposalsTab,
32
+ governance: GovernanceTab,
33
+ history: HistoryTab,
34
+ }
35
+
36
+ const activeComponent = computed(() => TAB_COMPONENTS[activeTab.value as keyof typeof TAB_COMPONENTS])
37
+
38
+ const { lastRefresh, error: eventsError } = useDaoEvents(1)
39
+ const { epoch } = useEpoch()
40
+ </script>
41
+
42
+ <template>
43
+ <div class="dao-view">
44
+ <div class="dao-toolbar">
45
+ <button class="dao-toolbar__btn" @click="activeTab = 'overview'">🏛 Meddleware DAO</button>
46
+ <span class="dao-toolbar__sep" />
47
+ <span class="dao-muted" style="font-size: 0.75rem; padding: 0 4px">
48
+ access_gate · testnet
49
+ </span>
50
+ </div>
51
+
52
+ <TabBar :tabs="TABS" v-model="activeTab" />
53
+
54
+ <div class="dao-content">
55
+ <component :is="activeComponent" />
56
+ </div>
57
+
58
+ <StatusBar
59
+ :epoch="epoch"
60
+ :last-refresh="lastRefresh"
61
+ :error="!!eventsError"
62
+ />
63
+ </div>
64
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ mist: bigint | null
4
+ symbol?: string
5
+ muted?: boolean
6
+ }>()
7
+
8
+ function format(mist: bigint, symbol: string): string {
9
+ if (mist === 0n) return `0 ${symbol}`
10
+ const n = Number(mist) / 1e9
11
+ const s = n >= 0.0001 ? n.toFixed(4) : n.toFixed(9).replace(/0+$/, '').replace(/\.$/, '')
12
+ return `${s} ${symbol}`
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <span class="dao-amount" :class="{ 'dao-amount--muted': muted }">
18
+ <template v-if="mist !== null">{{ format(mist, symbol ?? 'SUI') }}</template>
19
+ <template v-else>—</template>
20
+ </span>
21
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ empty?: string }>()
3
+
4
+ const slots = defineSlots<{
5
+ head(): unknown
6
+ default(): unknown
7
+ }>()
8
+ </script>
9
+
10
+ <template>
11
+ <div style="overflow-x: auto">
12
+ <table class="dao-table">
13
+ <thead>
14
+ <tr>
15
+ <slot name="head" />
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <slot />
20
+ </tbody>
21
+ </table>
22
+ <p v-if="empty && !slots.default" class="dao-placeholder">{{ empty }}</p>
23
+ </div>
24
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ title?: string }>()
3
+ </script>
4
+
5
+ <template>
6
+ <div class="dao-panel">
7
+ <div v-if="title" class="dao-panel__head">{{ title }}</div>
8
+ <div class="dao-panel__body">
9
+ <slot />
10
+ </div>
11
+ </div>
12
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Proposal } from '../composables/useProposals.js'
4
+
5
+ const props = defineProps<{
6
+ proposal: Proposal
7
+ currentEpoch: number | null
8
+ }>()
9
+
10
+ const pct = computed(() => {
11
+ if (props.proposal.targetMist === 0n) return 0
12
+ return Math.min(100, Number((props.proposal.contributedMist * 100n) / props.proposal.targetMist))
13
+ })
14
+
15
+ const epochsLeft = computed(() => {
16
+ if (props.currentEpoch === null) return null
17
+ return props.proposal.endEpoch - props.currentEpoch
18
+ })
19
+
20
+ const statusBadge = computed(() => {
21
+ if (props.proposal.status === 'active') return 'dao-badge--active'
22
+ if (props.proposal.status === 'pending') return 'dao-badge--pending'
23
+ return 'dao-badge--closed'
24
+ })
25
+
26
+ function formatSui(mist: bigint): string {
27
+ const n = Number(mist) / 1e9
28
+ return n.toFixed(2)
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <div class="dao-panel" style="margin-bottom: 8px">
34
+ <div class="dao-panel__body">
35
+ <div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 6px">
36
+ <div>
37
+ <span :class="['dao-badge', statusBadge]" style="margin-right: 6px">{{ proposal.status }}</span>
38
+ <strong style="font-size: 0.85rem">{{ proposal.title }}</strong>
39
+ </div>
40
+ <span class="dao-muted" style="white-space: nowrap; font-family: var(--font-mono); font-size: 0.75rem">
41
+ <template v-if="epochsLeft !== null && epochsLeft > 0">{{ epochsLeft }} epochs left</template>
42
+ <template v-else-if="epochsLeft !== null && epochsLeft <= 0">Expired</template>
43
+ </span>
44
+ </div>
45
+
46
+ <p class="dao-muted" style="margin: 0 0 8px; font-size: 0.78rem">{{ proposal.description }}</p>
47
+
48
+ <div style="margin-bottom: 4px">
49
+ <div class="dao-progress">
50
+ <div class="dao-progress__fill" :style="{ width: `${pct}%` }" />
51
+ </div>
52
+ <div style="display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--muted); font-family: var(--font-mono); margin-top: 2px">
53
+ <span>{{ formatSui(proposal.contributedMist) }} SUI raised</span>
54
+ <span>{{ pct }}% of {{ formatSui(proposal.targetMist) }} SUI target</span>
55
+ </div>
56
+ </div>
57
+
58
+ <button
59
+ class="dao-toolbar__btn"
60
+ disabled
61
+ title="Vault DAO governance module launching soon"
62
+ style="margin-top: 6px; opacity: 0.5; cursor: not-allowed"
63
+ >
64
+ Contribute
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { NETWORK } from '../config.js'
4
+
5
+ const props = defineProps<{
6
+ epoch: number | null
7
+ lastRefresh: Date | null
8
+ error?: boolean
9
+ }>()
10
+
11
+ const dotClass = computed(() => (props.error ? 'dao-statusbar__dot--error' : 'dao-statusbar__dot--ok'))
12
+
13
+ function timeAgo(d: Date): string {
14
+ const s = Math.floor((Date.now() - d.getTime()) / 1000)
15
+ if (s < 5) return 'just now'
16
+ if (s < 60) return `${s}s ago`
17
+ return `${Math.floor(s / 60)}m ago`
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <footer class="dao-statusbar">
23
+ <span class="dao-statusbar__item">
24
+ <span class="dao-statusbar__dot" :class="dotClass" />
25
+ {{ NETWORK.charAt(0).toUpperCase() + NETWORK.slice(1) }}
26
+ </span>
27
+ <span class="dao-statusbar__sep">│</span>
28
+ <span v-if="epoch !== null" class="dao-statusbar__item">Epoch {{ epoch }}</span>
29
+ <span v-else class="dao-statusbar__item dao-muted">Epoch —</span>
30
+ <span class="dao-statusbar__sep">│</span>
31
+ <span v-if="lastRefresh" class="dao-statusbar__item">Refreshed {{ timeAgo(lastRefresh) }}</span>
32
+ <span v-else class="dao-statusbar__item dao-muted">Loading…</span>
33
+ </footer>
34
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ export interface Tab {
3
+ id: string
4
+ label: string
5
+ }
6
+
7
+ defineProps<{
8
+ tabs: Tab[]
9
+ modelValue: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [id: string]
14
+ }>()
15
+ </script>
16
+
17
+ <template>
18
+ <nav class="dao-tabs" aria-label="DAO sections">
19
+ <button
20
+ v-for="tab in tabs"
21
+ :key="tab.id"
22
+ class="dao-tab"
23
+ :class="{ 'dao-tab--active': modelValue === tab.id }"
24
+ :aria-current="modelValue === tab.id ? 'page' : undefined"
25
+ @click="emit('update:modelValue', tab.id)"
26
+ >
27
+ {{ tab.label }}
28
+ </button>
29
+ </nav>
30
+ </template>
@@ -0,0 +1,70 @@
1
+ import { ref, onMounted } from 'vue'
2
+ import { getSuiClient } from '../wallet.js'
3
+ import { PACKAGE_ID } from '../config.js'
4
+
5
+ export interface DaoEvent {
6
+ type: 'AccessMinted' | 'AccessConsumed' | 'GateCreated' | 'AccessBurned'
7
+ txDigest: string
8
+ timestampMs: number
9
+ address?: string
10
+ }
11
+
12
+ export function useDaoEvents(limit = 20) {
13
+ const events = ref<DaoEvent[]>([])
14
+ const loading = ref(false)
15
+ const error = ref<string | null>(null)
16
+ const lastRefresh = ref<Date | null>(null)
17
+
18
+ async function load() {
19
+ if (!PACKAGE_ID) return
20
+ loading.value = true
21
+ error.value = null
22
+ try {
23
+ const client = getSuiClient()
24
+ const eventTypes = [
25
+ `${PACKAGE_ID}::access_gate::AccessMintedEvent`,
26
+ `${PACKAGE_ID}::access_gate::AccessConsumedEvent`,
27
+ `${PACKAGE_ID}::access_gate::GateCreatedEvent`,
28
+ ]
29
+
30
+ const results = await Promise.allSettled(
31
+ eventTypes.map((t) =>
32
+ client.listEvents({ filter: { eventType: t }, limit, order: 'descending' }),
33
+ ),
34
+ )
35
+
36
+ const all: DaoEvent[] = []
37
+ for (const r of results) {
38
+ if (r.status !== 'fulfilled') continue
39
+ for (const e of r.value.events) {
40
+ const typeName = e.eventType.split('::').pop() ?? ''
41
+ const label =
42
+ typeName === 'AccessMintedEvent' ? 'AccessMinted'
43
+ : typeName === 'AccessConsumedEvent' ? 'AccessConsumed'
44
+ : typeName === 'GateCreatedEvent' ? 'GateCreated'
45
+ : 'AccessBurned'
46
+ const f = (e.json ?? {}) as Record<string, unknown>
47
+ all.push({
48
+ type: label as DaoEvent['type'],
49
+ txDigest: e.transactionDigest,
50
+ timestampMs: 0,
51
+ address: String(f.recipient ?? f.creator ?? f.sender ?? ''),
52
+ })
53
+ }
54
+ }
55
+
56
+ // Events come back in descending order per-type; stable sort across types by txDigest.
57
+ // timestampMs is not available from gRPC EventEntry; order is already newest-first per batch.
58
+ events.value = all.slice(0, limit)
59
+ lastRefresh.value = new Date()
60
+ } catch (e) {
61
+ error.value = e instanceof Error ? e.message : String(e)
62
+ } finally {
63
+ loading.value = false
64
+ }
65
+ }
66
+
67
+ onMounted(load)
68
+
69
+ return { events, loading, error, lastRefresh, reload: load }
70
+ }
@@ -0,0 +1,20 @@
1
+ import { ref, onMounted } from 'vue'
2
+ import { getSuiClient } from '../wallet.js'
3
+
4
+ export function useEpoch() {
5
+ const epoch = ref<number | null>(null)
6
+
7
+ async function load() {
8
+ try {
9
+ const client = getSuiClient()
10
+ const res = await client.getCurrentSystemState()
11
+ epoch.value = Number(res.systemState.epoch)
12
+ } catch {
13
+ // non-fatal — status bar shows "Epoch —"
14
+ }
15
+ }
16
+
17
+ onMounted(load)
18
+
19
+ return { epoch, reload: load }
20
+ }
@@ -0,0 +1,53 @@
1
+ import { ref, onMounted } from 'vue'
2
+ import { getSuiClient } from '../wallet.js'
3
+ import { PACKAGE_ID } from '../config.js'
4
+
5
+ export interface Gate {
6
+ id: string
7
+ name: string
8
+ price: bigint
9
+ paused: boolean
10
+ frozen: boolean
11
+ txDigest: string
12
+ timestampMs: number
13
+ }
14
+
15
+ export function useGates() {
16
+ const gates = ref<Gate[]>([])
17
+ const loading = ref(false)
18
+ const error = ref<string | null>(null)
19
+
20
+ async function load() {
21
+ if (!PACKAGE_ID) return
22
+ loading.value = true
23
+ error.value = null
24
+ try {
25
+ const client = getSuiClient()
26
+ const events = await client.listEvents({
27
+ filter: { eventType: `${PACKAGE_ID}::access_gate::GateCreatedEvent` },
28
+ limit: 50,
29
+ order: 'descending',
30
+ })
31
+ gates.value = events.events.map((e) => {
32
+ const f = (e.json ?? {}) as Record<string, unknown>
33
+ return {
34
+ id: String(f.gate_id ?? ''),
35
+ name: String(f.nft_name ?? 'Unnamed Gate'),
36
+ price: BigInt(String(f.price ?? '0')),
37
+ paused: false,
38
+ frozen: false,
39
+ txDigest: e.transactionDigest,
40
+ timestampMs: 0,
41
+ }
42
+ })
43
+ } catch (e) {
44
+ error.value = e instanceof Error ? e.message : String(e)
45
+ } finally {
46
+ loading.value = false
47
+ }
48
+ }
49
+
50
+ onMounted(load)
51
+
52
+ return { gates, loading, error, reload: load }
53
+ }
@@ -0,0 +1,38 @@
1
+ import { ref, onMounted } from 'vue'
2
+ import { getSuiClient } from '../wallet.js'
3
+ import { CONFIG_ID } from '../config.js'
4
+
5
+ export interface PlatformConfig {
6
+ treasury: string
7
+ commissionBps: number
8
+ }
9
+
10
+ export function usePlatformConfig() {
11
+ const config = ref<PlatformConfig | null>(null)
12
+ const loading = ref(false)
13
+ const error = ref<string | null>(null)
14
+
15
+ async function load() {
16
+ if (!CONFIG_ID) { error.value = 'PlatformConfig ID not configured'; return }
17
+ loading.value = true
18
+ error.value = null
19
+ try {
20
+ const client = getSuiClient()
21
+ const res = await client.getObject({ objectId: CONFIG_ID, include: { json: true } })
22
+ const fields = res.object.json as Record<string, unknown> | null
23
+ if (!fields) throw new Error('unexpected object structure')
24
+ config.value = {
25
+ treasury: String(fields.treasury),
26
+ commissionBps: Number(fields.commission_bps),
27
+ }
28
+ } catch (e) {
29
+ error.value = e instanceof Error ? e.message : String(e)
30
+ } finally {
31
+ loading.value = false
32
+ }
33
+ }
34
+
35
+ onMounted(load)
36
+
37
+ return { config, loading, error, reload: load }
38
+ }
@@ -0,0 +1,57 @@
1
+ // Proposals are backed by mock data until vault_dao is deployed on-chain.
2
+ // When vault_dao ships, replace this composable with one that queries the on-chain
3
+ // proposal registry. The Proposal type and return shape are kept stable.
4
+
5
+ import { ref, readonly } from 'vue'
6
+
7
+ export type ProposalStatus = 'active' | 'pending' | 'closed'
8
+
9
+ export interface Proposal {
10
+ id: string
11
+ title: string
12
+ description: string
13
+ targetMist: bigint
14
+ contributedMist: bigint
15
+ endEpoch: number
16
+ status: ProposalStatus
17
+ }
18
+
19
+ const MOCK_PROPOSALS: Proposal[] = [
20
+ {
21
+ id: 'prop-001',
22
+ title: 'mwSUI Vault Strategy — Haedal LST Allocation',
23
+ description:
24
+ 'Allocate 10% of vault AUM to haSUI (Haedal liquid-staked SUI) to diversify yield sources beyond Spring Finance sSUI. Requires at least 50 SUI in community endorsement to enact.',
25
+ targetMist: 50_000_000_000n,
26
+ contributedMist: 0n,
27
+ endEpoch: 620,
28
+ status: 'active',
29
+ },
30
+ {
31
+ id: 'prop-002',
32
+ title: 'Commission Rate Reduction — 0.20% → 0.15%',
33
+ description:
34
+ 'Reduce the access-gate platform commission from 20 bps to 15 bps to stay competitive with comparable NFT-gated relay services.',
35
+ targetMist: 25_000_000_000n,
36
+ contributedMist: 0n,
37
+ endEpoch: 640,
38
+ status: 'active',
39
+ },
40
+ {
41
+ id: 'prop-003',
42
+ title: 'Sealed Storage Relay Integration',
43
+ description:
44
+ 'Route Seal uploads through the Meddleware Walrus relay so relay tips are captured and included in the fee distribution cycle.',
45
+ targetMist: 100_000_000_000n,
46
+ contributedMist: 0n,
47
+ endEpoch: 680,
48
+ status: 'pending',
49
+ },
50
+ ]
51
+
52
+ export function useProposals() {
53
+ const proposals = ref<Proposal[]>(MOCK_PROPOSALS)
54
+ const loading = ref(false)
55
+
56
+ return { proposals: readonly(proposals), loading: readonly(loading) }
57
+ }
@@ -0,0 +1,30 @@
1
+ import { ref, watch } from 'vue'
2
+ import { getSuiClient } from '../wallet.js'
3
+
4
+ export function useTreasury(treasuryAddress: () => string | null) {
5
+ const balance = ref<bigint | null>(null)
6
+ const loading = ref(false)
7
+ const error = ref<string | null>(null)
8
+
9
+ async function load(address: string) {
10
+ loading.value = true
11
+ error.value = null
12
+ try {
13
+ const client = getSuiClient()
14
+ const res = await client.getBalance({ owner: address, coinType: '0x2::sui::SUI' })
15
+ balance.value = BigInt(res.balance.coinBalance)
16
+ } catch (e) {
17
+ error.value = e instanceof Error ? e.message : String(e)
18
+ } finally {
19
+ loading.value = false
20
+ }
21
+ }
22
+
23
+ watch(
24
+ treasuryAddress,
25
+ (addr) => { if (addr) { void load(addr) } else { balance.value = null } },
26
+ { immediate: true },
27
+ )
28
+
29
+ return { balance, loading, error }
30
+ }