5 Commits
Author SHA1 Message Date
TZGyn d80b58b704 Point publish instructions at Gitea 2026-09-11 16:21:39 +08:00
TZGyn babd85e614 add vue lynx mobile wallet 2026-08-09 18:09:21 +08:00
TZGyn d15152a013 bump version to 0.0.12 2026-08-04 22:19:25 +08:00
TZGyn 3913889a22 add portfolio allocation donut chart on dashboard 2026-08-04 22:19:13 +08:00
TZGyn da0c56658b add QR image upload to send pages 2026-08-04 22:19:03 +08:00
28 changed files with 4269 additions and 28 deletions
+1 -1
View File
@@ -5,6 +5,6 @@
- Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location. - Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location.
- Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message. - Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message.
- When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build. - When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build.
- Never publish without explicit permission. Before any publish action — running `bun run build:stable`, `gh release upload`, updating the Homebrew cask, or pushing the tap repo — describe the intended action (what will be built, what SHA, which release) and ask for permission. Once you have asked and received a clear affirmative (any clear yes, "go", "publish", etc.), you may proceed through the whole pipeline without pausing between steps: build, notarize, verify, upload to the GitHub release, update the Homebrew cask, and push the tap repo. One permission covers the entire publish flow. You must still get a separate permission before starting each new publish cycle. - Never publish without explicit permission. Before any publish action — running `bun run build:stable`, uploading a release asset to Gitea (`https://gitea.tzgyn.dev/TZGyn/koins`), updating the Homebrew cask, or pushing the tap repo (`https://gitea.tzgyn.dev/TZGyn/homebrew-tap`) — describe the intended action (what will be built, what SHA, which release) and ask for permission. Once you have asked and received a clear affirmative (any clear yes, "go", "publish", etc.), you may proceed through the whole pipeline without pausing between steps: build, notarize, verify, upload to the Gitea release, update the Homebrew cask, and push the Gitea tap repo. One permission covers the entire publish flow. You must still get a separate permission before starting each new publish cycle.
- Permission to commit ("commit", "commit and push", or similar) authorizes `git commit` and also `git push` of the committed changes. Only push the branches/files described in the permission. - Permission to commit ("commit", "commit and push", or similar) authorizes `git commit` and also `git push` of the committed changes. Only push the branches/files described in the permission.
- At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." List each pending item as a numbered entry (`1.`, `2.`, …) so the user can approve or act on them by number (e.g. "go ahead with 1, 2"). - At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." List each pending item as a numbered entry (`1.`, `2.`, …) so the user can approve or act on them by number (e.g. "go ahead with 1, 2").
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
app: { app: {
name: 'koins', name: 'koins',
identifier: 'koins.tzgyn.com', identifier: 'koins.tzgyn.com',
version: '0.0.11', version: '0.0.12',
}, },
build: { build: {
// Vite builds to dist/, we copy from there // Vite builds to dist/, we copy from there
+1
View File
@@ -20,6 +20,7 @@
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"electrobun": "^1.18.1", "electrobun": "^1.18.1",
"env-paths": "^4.0.0", "env-paths": "^4.0.0",
"jsqr": "^1.4.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"monero-ts": "^0.11.8", "monero-ts": "^0.11.8",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -0,0 +1,62 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'
import Loader from '$lib/components/loader.svelte'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import { decodeQrFromFile } from '$lib/qr.js'
let {
onDecode,
disabled = false,
}: {
onDecode: (text: string) => void
disabled?: boolean
} = $props()
let fileInput: HTMLInputElement
let busy = $state(false)
let error = $state('')
const handleFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
busy = true
error = ''
try {
const text = await decodeQrFromFile(file)
if (!text) throw new Error('No QR code found in the image')
onDecode(text)
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to read QR code'
} finally {
busy = false
if (fileInput) fileInput.value = ''
}
}
</script>
<div class="flex items-center gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={handleFile}
disabled={disabled || busy} />
<Button
type="button"
variant="outline"
size="sm"
class="h-7 gap-1.5"
disabled={disabled || busy}
onclick={() => fileInput?.click()}>
{#if busy}
<Loader class="size-3.5" />
{:else}
<QrCodeIcon size={14} />
{/if}
Scan QR
</Button>
{#if error}
<span class="text-xs text-red-500">{error}</span>
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
import jsQR from 'jsqr'
export const decodeQrFromFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas is not supported')
ctx.drawImage(img, 0, 0)
const { data, width, height } = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
)
resolve(jsQR(data, width, height)?.data ?? '')
} catch (e) {
reject(e)
} finally {
URL.revokeObjectURL(url)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Could not load the image'))
}
img.src = url
})
export type QrParseResult = {
address: string
amount?: string
destinationTag?: string
}
// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..)
// as well as bare addresses. Returns the last path-like segment before any
// query string so prefixes and /pay paths are skipped.
export const parseQrPayload = (
text: string,
kind: 'xrp' | 'monero',
): QrParseResult => {
let body = text.trim().replace(/\s+/g, '')
body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '')
const [beforeQuery, queryRaw] = body.split('?')
const segments = beforeQuery.split('/')
const address = segments[segments.length - 1]
const params: Record<string, string> = {}
if (queryRaw) {
for (const pair of queryRaw.split('&')) {
const eq = pair.indexOf('=')
const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase()
const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : ''
if (key) params[key] = value
}
}
if (kind === 'xrp') {
return {
address,
amount: params['amount'] || undefined,
destinationTag: params['dt'] || undefined,
}
}
return {
address,
amount: params['tx_amount'] || undefined,
}
}
+69
View File
@@ -15,6 +15,9 @@
import WalletIcon from '@lucide/svelte/icons/wallet' import WalletIcon from '@lucide/svelte/icons/wallet'
import Loader from '$lib/components/loader.svelte' import Loader from '$lib/components/loader.svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js' import { frontendLog, frontendLogError } from '$lib/electrobun.js'
import { arc } from 'd3-shape'
import { tweened } from 'svelte/motion'
import { cubicOut } from 'svelte/easing'
let feError = $state<string | null>(null) let feError = $state<string | null>(null)
@@ -43,6 +46,33 @@
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd) const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
const xrpShare = $derived(portfolioUsd > 0 ? xrpTotalUsd / portfolioUsd : 0)
const xmrShare = $derived(
portfolioUsd > 0 ? xmrTotalUsd / portfolioUsd : 0,
)
const tweenedUsd = tweened(0, { duration: 600, easing: cubicOut })
const tweenedXrpShare = tweened(0, { duration: 600, easing: cubicOut })
$effect(() => {
tweenedUsd.set(portfolioUsd)
tweenedXrpShare.set(xrpShare)
})
const donut = $derived.by(() => {
if (portfolioUsd <= 0) return { xrp: '', xmr: '' }
const a = arc<{ startAngle: number; endAngle: number }>()
.innerRadius(62)
.outerRadius(80)
.padAngle(0.02)
const start = -Math.PI / 2
const endXrp = start + $tweenedXrpShare * 2 * Math.PI
return {
xrp: a({ startAngle: start, endAngle: endXrp }) ?? '',
xmr: a({ startAngle: endXrp, endAngle: start + 2 * Math.PI }) ?? '',
}
})
const hasXrp = $derived(xrpWallet.wallets.length > 0) const hasXrp = $derived(xrpWallet.wallets.length > 0)
const hasXmr = $derived(moneroWallet.wallets.length > 0) const hasXmr = $derived(moneroWallet.wallets.length > 0)
const noWallets = $derived(!hasXrp && !hasXmr) const noWallets = $derived(!hasXrp && !hasXmr)
@@ -69,6 +99,8 @@
maximumFractionDigits: dp, maximumFractionDigits: dp,
}) })
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
onMount(() => { onMount(() => {
frontendLog('dashboard mounted') frontendLog('dashboard mounted')
}) })
@@ -154,6 +186,43 @@
{/if} {/if}
</div> </div>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">Portfolio Allocation</CardTitle>
<CardDescription class="text-xs">
Share of your balance by asset
</CardDescription>
</CardHeader>
<CardContent class="flex flex-col items-center gap-5 py-6">
<div class="relative">
<svg viewBox="-100 -100 200 200" class="size-48">
<path d={donut.xrp} fill="#0EA5E9" />
<path d={donut.xmr} fill="#F97316" />
</svg>
<div
class="absolute inset-0 flex flex-col items-center justify-center">
<p
class="text-lg font-semibold tabular-nums">$
{fmtUsd($tweenedUsd)}
</p>
<p class="text-xs text-muted-foreground">
Portfolio
</p>
</div>
</div>
<div class="flex gap-5 text-xs">
<span class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full bg-sky-500"></span>
XRP · {fmtPct($tweenedXrpShare)}
</span>
<span class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full bg-orange-500"></span>
XMR · {fmtPct(1 - $tweenedXrpShare)}
</span>
</div>
</CardContent>
</Card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<button <button
onclick={() => navigate('/xrp')} onclick={() => navigate('/xrp')}
@@ -19,6 +19,8 @@
SelectTrigger, SelectTrigger,
} from '$lib/components/ui/select/index.js' } from '$lib/components/ui/select/index.js'
import { navigate } from 'sv-router/generated' import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = moneroWallet const w = moneroWallet
@@ -134,7 +136,15 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-xs font-medium">Recipient Address</label> <label class="text-xs font-medium">Recipient Address</label>
<QrUpload
onDecode={(text) => {
const parsed = parseQrPayload(text, 'monero')
recipient = parsed.address
if (parsed.amount && !sendAll) amountXmr = parsed.amount
}} />
</div>
<Input <Input
placeholder="Monero address starting with 4..." placeholder="Monero address starting with 4..."
bind:value={recipient} /> bind:value={recipient} />
+11
View File
@@ -10,6 +10,8 @@
CardDescription, CardDescription,
} from '$lib/components/ui/card/index.js' } from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated' import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = xrpWallet const w = xrpWallet
@@ -78,7 +80,16 @@
}} }}
class="space-y-4"> class="space-y-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-xs font-medium">Recipient Address</label> <label class="text-xs font-medium">Recipient Address</label>
<QrUpload
onDecode={(text) => {
const parsed = parseQrPayload(text, 'xrp')
recipient = parsed.address
if (parsed.destinationTag) destinationTag = parsed.destinationTag
if (parsed.amount) amountXrp = parsed.amount
}} />
</div>
<Input <Input
placeholder="XRP address starting with r..." placeholder="XRP address starting with r..."
bind:value={recipient} /> bind:value={recipient} />
+16
View File
@@ -0,0 +1,16 @@
# Local
.DS_Store
*.local
*.log*
# Dist
node_modules
dist/
# IDE
.vscode/*
!.vscode/extensions.json
.idea
# TypeScript
*.tsbuildinfo
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+24
View File
@@ -0,0 +1,24 @@
# AGENTS.md
You are an expert in JavaScript, Rspeedy, and Vue Lynx application development. You write maintainable, performant, and accessible code.
## Read in Advance
- Vue Lynx: [llms.txt](https://vue.lynxjs.org/llms.txt), **REQUIRED**.
While dealing with a Vue Lynx task, an agent **MUST** read this doc because it is an entry point of all available docs about Vue Lynx.
## Commands
- `npm run dev` - Start the dev server
- `npm run build` - Build the app for production
- `npm run preview` - Preview the production build locally
- `npm exec rspeedy inspect` - Inspect the Rspeedy config and Rspack config of the project.
## Related Docs
- Rsbuild: <https://rsbuild.rs/llms.txt>
- Rspack: <https://rspack.rs/llms.txt>
+27
View File
@@ -0,0 +1,27 @@
## Vue Lynx project
This is a Vue Lynx project bootstrapped with `create-vue-lynx`.
## IDE Setup
This project is pre-configured for [Vue Language Features (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.volar). VS Code will prompt you to install it automatically.
Volar provides prop completions, type checking, and Lynx-specific diagnostics for `.vue` files.
## Getting Started
First, install the dependencies:
```bash
pnpm install
```
Then, run the development server:
```bash
pnpm run dev
```
Scan the QRCode in the terminal with your LynxExplorer App to see the result.
You can start editing the page by modifying `src/App.vue`. The page auto-updates as you edit the file.
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from '@lynx-js/rspeedy'
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
import { pluginVueLynx } from 'vue-lynx/plugin'
import { pluginTailwindCSS } from 'rsbuild-plugin-tailwindcss'
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'
export default defineConfig({
environments: {
lynx: {
resolve: {
alias: {
// crypto-browserify -> create-hash -> require('crypto') forms a
// circular dependency; webpack resolves createHash to undefined in
// the lynx build. Point create-hash at its browser implementation
// (no require('crypto') cycle).
'create-hash$': '/Users/tzgyn/Git/koins/node_modules/.bun/create-hash@1.2.0/node_modules/create-hash/browser.js',
},
},
},
web: {},
},
plugins: [
pluginNodePolyfill(),
pluginQRCode({
schema(url) {
return `${url}?fullscreen=true`
},
}),
pluginVueLynx({
optionsApi: false,
enableCSSInlineVariables: true,
enableCSSInheritance: true,
}),
pluginTailwindCSS({
config: 'tailwind.config.ts',
exclude: [/[\\/]node_modules[\\/]/],
}),
],
})
+28
View File
@@ -0,0 +1,28 @@
{
"name": "mobile",
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "rspeedy build",
"dev": "rspeedy dev",
"preview": "rspeedy preview"
},
"dependencies": {
"vue": "^3.5.0",
"vue-lynx": "^0.5.1",
"xrpl": "^5.0.0"
},
"devDependencies": {
"@lynx-js/qrcode-rsbuild-plugin": "^0.4.6",
"@lynx-js/rspeedy": "^0.13.5",
"@lynx-js/tailwind-preset": "^0.5.1",
"@rsbuild/plugin-node-polyfill": "^1.4.6",
"@rsbuild/plugin-vue": "^1.2.6",
"rsbuild-plugin-tailwindcss": "^0.2.4",
"tailwindcss": "3",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
tailwindcss: {},
},
}
+29
View File
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind utilities;
:root {
background-color: #0a0a0b;
color: #fafafa;
}
page {
background-color: #0a0a0b;
}
input {
background-color: #18181b;
color: #fafafa;
caret-color: #fafafa;
}
scroll-view {
background-color: transparent;
}
view {
background-color: transparent;
}
text {
color: #fafafa;
}
+399
View File
@@ -0,0 +1,399 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue-lynx'
import './App.css'
import {
generateWallet,
walletFromSecret,
isValidAddress,
getWallet,
saveWallet,
deleteWallet,
getSeed,
saveSeed,
getBalance,
getTransactions,
sendPayment,
type XrpWalletInfo,
type XrpBalance,
type XrpTxEntry,
} from './lib/xrp'
type Screen =
| 'welcome'
| 'create'
| 'import'
| 'dashboard'
| 'send'
| 'receive'
const screen = ref<Screen>('welcome')
const wallet = ref<XrpWalletInfo | null>(null)
const seed = ref<string | null>(null)
const balance = ref<XrpBalance | null>(null)
const transactions = ref<XrpTxEntry[]>([])
const loading = ref(false)
const error = ref('')
const success = ref('')
const newName = ref('')
const newMnemonic = ref('')
const importName = ref('')
const importSecret = ref('')
const createdSeed = ref('')
const sendTo = ref('')
const sendAmount = ref('')
const sendTag = ref('')
const sending = ref(false)
const showAddress = computed(() => wallet.value?.address ?? '')
const truncatedAddress = computed(() => {
const a = showAddress.value
if (a.length <= 16) return a
return `${a.slice(0, 8)}...${a.slice(-8)}`
})
onMounted(() => {
wallet.value = getWallet()
if (wallet.value) {
seed.value = getSeed()
screen.value = 'dashboard'
refresh()
}
})
async function refresh() {
const w = wallet.value
if (!w) return
loading.value = true
error.value = ''
try {
const [bal, txs] = await Promise.all([
getBalance(w.address),
getTransactions(w.address),
])
balance.value = bal
transactions.value = txs
} catch (e) {
console.info('[koins] refresh error:', e instanceof Error ? e.message : String(e))
error.value = e instanceof Error ? e.message : 'Failed to load wallet'
} finally {
loading.value = false
}
}
function handleCreate() {
error.value = ''
if (!newName.value.trim()) {
error.value = 'Enter a wallet name'
return
}
try {
const { address, seed: newSeed } = generateWallet()
const w: XrpWalletInfo = {
id: `xrp_${Date.now()}`,
name: newName.value.trim(),
address,
createdAt: new Date().toISOString(),
}
wallet.value = w
seed.value = newSeed
saveWallet(w)
saveSeed(newSeed)
createdSeed.value = newSeed
newName.value = ''
screen.value = 'create'
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create wallet'
}
}
function finishCreate() {
createdSeed.value = ''
screen.value = 'dashboard'
refresh()
}
function handleImport() {
error.value = ''
if (!importName.value.trim()) {
error.value = 'Enter a wallet name'
return
}
if (!importSecret.value.trim()) {
error.value = 'Enter your seed or secret'
return
}
try {
const { address } = walletFromSecret(importSecret.value)
const w: XrpWalletInfo = {
id: `xrp_${Date.now()}`,
name: importName.value.trim(),
address,
createdAt: new Date().toISOString(),
}
wallet.value = w
seed.value = importSecret.value.trim()
saveWallet(w)
saveSeed(importSecret.value.trim())
importName.value = ''
importSecret.value = ''
screen.value = 'dashboard'
refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to import wallet'
}
}
function handleSend() {
error.value = ''
if (!isValidAddress(sendTo.value.trim())) {
error.value = 'Invalid XRP address'
return
}
const amount = parseFloat(sendAmount.value)
if (!amount || amount <= 0) {
error.value = 'Enter a valid amount'
return
}
if (!seed.value) {
error.value = 'Wallet is locked'
return
}
const bal = parseFloat(balance.value?.balance ?? '0')
if (amount > bal) {
error.value = 'Insufficient balance'
return
}
sending.value = true
sendPayment(
seed.value,
sendTo.value.trim(),
sendAmount.value,
sendTag.value ? parseInt(sendTag.value, 10) : undefined,
)
.then(() => {
success.value = 'Payment sent'
sendTo.value = ''
sendAmount.value = ''
sendTag.value = ''
refresh()
})
.catch((e) => {
error.value = e instanceof Error ? e.message : 'Failed to send'
})
.finally(() => {
sending.value = false
})
}
function handleDelete() {
if (!wallet.value) return
deleteWallet()
wallet.value = null
seed.value = null
balance.value = null
transactions.value = []
screen.value = 'welcome'
}
function goDashboard() {
screen.value = 'dashboard'
refresh()
}
function formatDate(ts: number): string {
if (!ts) return 'pending'
const d = new Date(ts * 1000)
const month = d.toLocaleDateString(undefined, { month: 'short' })
return `${month} ${d.getDate()}`
}
function truncateHash(hash: string): string {
if (hash.length <= 14) return hash
return `${hash.slice(0, 7)}...${hash.slice(-7)}`
}
</script>
<template>
<view class="app flex-1 bg-background px-5 pt-12 pb-6">
<!-- Welcome -->
<view v-if="screen === 'welcome'" class="flex flex-col items-center">
<text class="mt-12 text-5xl font-bold text-foreground">Koins</text>
<text class="mt-1 mb-6 text-lg text-muted">XRP Wallet</text>
<text class="mb-4 text-sm text-center text-muted">
Create or import your XRP wallet
</text>
<view class="mt-5 flex w-full flex-row gap-3">
<view class="flex-1 bg-primary h-[52px] rounded-xl items-center justify-center" @tap="screen = 'create'">
<text class="text-base font-semibold text-primary-foreground">Create Wallet</text>
</view>
<view class="flex-1 bg-zinc-800 h-[52px] rounded-xl items-center justify-center" @tap="screen = 'import'">
<text class="text-base font-semibold text-foreground">Import</text>
</view>
</view>
</view>
<!-- Create -->
<view v-else-if="screen === 'create'" class="flex flex-col items-center">
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Create Wallet</text>
<text v-if="!createdSeed" class="mb-4 text-sm text-center text-muted">Choose a name for your new wallet</text>
<view v-if="!createdSeed" class="flex w-full flex-col gap-3">
<input
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
:value="newName"
@input="newName = $event.detail.value"
placeholder="Wallet name"
placeholder-class="text-muted"
/>
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleCreate">
<text class="text-base font-semibold text-primary-foreground">Generate Wallet</text>
</view>
</view>
<view v-else class="flex w-full flex-col gap-2.5 rounded-2xl border border-yellow-800 bg-yellow-950 p-5">
<text class="text-lg font-semibold text-yellow-200">Your Seed Phrase</text>
<text class="text-[13px] text-yellow-300">Write this down. It is the only way to recover your wallet. Never share it.</text>
<text class="rounded-[10px] border border-yellow-800 bg-card p-3.5 text-[15px] leading-6 text-foreground">
{{ createdSeed }}
</text>
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="finishCreate">
<text class="text-base font-semibold text-primary-foreground">I've Saved It</text>
</view>
</view>
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
</view>
<!-- Import -->
<view v-else-if="screen === 'import'" class="flex flex-col items-center">
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Import Wallet</text>
<view class="flex w-full flex-col gap-3">
<input
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
:value="importName"
@input="importName = $event.detail.value"
placeholder="Wallet name"
placeholder-class="text-muted"
/>
<input
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
:value="importSecret"
@input="importSecret = $event.detail.value"
placeholder="Seed phrase or secret"
placeholder-class="text-muted"
/>
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleImport">
<text class="text-base font-semibold text-primary-foreground">Import Wallet</text>
</view>
</view>
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
</view>
<!-- Dashboard -->
<view v-else-if="screen === 'dashboard'" class="flex flex-col items-center">
<view class="flex w-full flex-row items-start justify-between">
<view class="flex flex-col">
<text class="text-[17px] font-semibold text-foreground">{{ wallet?.name ?? 'Wallet' }}</text>
<text class="mt-0.5 text-[13px] text-muted">{{ truncatedAddress }}</text>
</view>
<view class="flex flex-row gap-3">
<text class="text-sm text-primary" @tap="handleDelete">Delete</text>
</view>
</view>
<view class="mt-5 flex w-full flex-col items-center rounded-2xl border border-border bg-card p-7">
<text class="text-sm text-muted">Total Balance</text>
<text class="mt-1.5 text-4xl font-bold text-foreground">{{ loading && !balance ? '...' : (balance?.balance ?? '0') }} XRP</text>
<text v-if="balance && !balance.funded" class="mt-1.5 text-[13px] text-yellow-400">Account not funded yet</text>
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
</view>
<view class="mt-5 flex w-full flex-row gap-3">
<view class="flex-1 bg-primary h-[52px] rounded-xl items-center justify-center" @tap="screen = 'send'">
<text class="text-base font-semibold text-primary-foreground">Send</text>
</view>
<view class="flex-1 bg-zinc-800 h-[52px] rounded-xl items-center justify-center" @tap="screen = 'receive'">
<text class="text-base font-semibold text-foreground">Receive</text>
</view>
</view>
<text class="self-start mt-7 mb-2 text-lg font-semibold text-foreground">Transactions</text>
<scroll-view class="tx-list flex w-full flex-col" :scroll-y="true">
<view v-if="transactions.length === 0 && !loading" class="py-6">
<text class="text-sm text-center text-muted">No transactions yet</text>
</view>
<view v-for="tx in transactions" :key="tx.hash" class="mb-2 flex w-full flex-row items-center rounded-xl bg-card p-3.5">
<view
class="mr-3 h-9 w-9 rounded-full items-center justify-center"
:class="tx.direction === 'in' ? 'bg-emerald-950' : 'bg-red-950'"
>
<text class="text-lg font-bold" :class="tx.direction === 'in' ? 'text-emerald-400' : 'text-red-400'">
{{ tx.direction === 'in' ? '↓' : '↑' }}
</text>
</view>
<view class="flex flex-1 flex-col">
<text class="text-[15px] font-medium text-foreground">{{ tx.direction === 'in' ? 'Received' : 'Sent' }}</text>
<text class="mt-0.5 text-xs text-muted">{{ formatDate(tx.timestamp) }} · {{ truncateHash(tx.hash) }}</text>
</view>
<text class="text-[15px] font-semibold" :class="tx.direction === 'in' ? 'text-emerald-400' : 'text-red-400'">
{{ tx.direction === 'in' ? '+' : '-' }}{{ tx.amount }}
</text>
</view>
</scroll-view>
</view>
<!-- Send -->
<view v-else-if="screen === 'send'" class="flex flex-col items-center">
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Send XRP</text>
<view class="flex w-full flex-col gap-3">
<input
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
:value="sendTo"
@input="sendTo = $event.detail.value"
placeholder="Destination address (r...)"
placeholder-class="text-muted"
/>
<input
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
:value="sendAmount"
@input="sendAmount = $event.detail.value"
placeholder="Amount (XRP)"
placeholder-class="text-muted"
/>
<input
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
:value="sendTag"
@input="sendTag = $event.detail.value"
placeholder="Destination tag (optional)"
placeholder-class="text-muted"
/>
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleSend">
<text class="text-base font-semibold text-primary-foreground">{{ sending ? 'Sending...' : 'Send' }}</text>
</view>
<view class="h-[52px] bg-zinc-800 w-full rounded-xl items-center justify-center" @tap="goDashboard">
<text class="text-base font-semibold text-foreground">Cancel</text>
</view>
</view>
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
<text v-if="success" class="mt-3 text-center text-[13px] text-success">{{ success }}</text>
</view>
<!-- Receive -->
<view v-else-if="screen === 'receive'" class="flex flex-col items-center">
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Receive XRP</text>
<view class="flex w-full flex-col items-center rounded-2xl border border-border bg-card p-7">
<text class="text-sm leading-6 text-center text-foreground">{{ showAddress }}</text>
<text class="mt-4 text-sm text-center text-muted">Share this address to receive XRP</text>
</view>
<view class="mt-5 h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="goDashboard">
<text class="text-base font-semibold text-primary-foreground">Done</text>
</view>
</view>
</view>
</template>
+20
View File
@@ -0,0 +1,20 @@
import './lib/text-encoder'
declare const lynxCoreInject: { tt?: Record<string, any> } | undefined
// The LynxExplorer SDK calls tt.callDestroyLifetimeFun() when destroying a
// card. vue-lynx's runtime does not define it, which throws during page
// teardown and can break subsequent loads. Provide the expected no-op hook.
if (typeof lynxCoreInject !== 'undefined' && lynxCoreInject?.tt) {
const tt = lynxCoreInject.tt
if (typeof tt.callDestroyLifetimeFun !== 'function') {
tt.callDestroyLifetimeFun = () => {}
}
}
import { createApp } from 'vue-lynx'
import App from './App.vue'
const app = createApp(App)
app.mount()
+38
View File
@@ -0,0 +1,38 @@
declare const lynx: {
getStorageSync(key: string): string
setStorageSync(key: string, value: string): void
removeStorageSync(key: string): void
} | undefined
function lynxStorageAvailable(): boolean {
return typeof lynx !== 'undefined' && typeof lynx.getStorageSync === 'function'
}
export function getItem(key: string): string | null {
try {
const ls = globalThis.localStorage?.getItem(key)
if (ls !== null && ls !== undefined) return ls
} catch {}
try {
if (lynxStorageAvailable()) return lynx!.getStorageSync(key)
} catch {}
return null
}
export function setItem(key: string, value: string): void {
try {
globalThis.localStorage?.setItem(key, value)
} catch {}
try {
if (lynxStorageAvailable()) lynx!.setStorageSync(key, value)
} catch {}
}
export function removeItem(key: string): void {
try {
globalThis.localStorage?.removeItem(key)
} catch {}
try {
if (lynxStorageAvailable()) lynx!.removeStorageSync(key)
} catch {}
}
+75
View File
@@ -0,0 +1,75 @@
// TextEncoder/TextDecoder polyfill for runtimes that lack them (Lynx PrimJS).
// Needed by @noble/hashes (xrpl crypto stack) and other WASM-adjacent libs.
// Must be imported BEFORE any module that transitively imports xrpl, because
// @noble/curves calls utf8ToBytes at module-evaluation time.
if (typeof globalThis.TextEncoder === 'undefined') {
const utf8Encode = (str: string): Uint8Array => {
const bytes: number[] = []
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i)
if (code < 0x80) {
bytes.push(code)
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
const low = str.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
i++
code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00)
bytes.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
)
} else {
bytes.push(0xef, 0xbf, 0xbd)
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
bytes.push(0xef, 0xbf, 0xbd)
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
}
}
return Uint8Array.from(bytes)
}
globalThis.TextEncoder = class TextEncoder {
encoding = 'utf-8'
encode(input: string = ''): Uint8Array {
return utf8Encode(input)
}
} as unknown as typeof TextEncoder
}
if (typeof globalThis.TextDecoder === 'undefined') {
globalThis.TextDecoder = class TextDecoder {
encoding = 'utf-8'
decode(bytes?: Uint8Array): string {
if (!bytes) return ''
let result = ''
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i]
if (b < 0x80) {
result += String.fromCharCode(b)
} else if (b < 0xe0 && i + 1 < bytes.length) {
result += String.fromCharCode(((b & 0x1f) << 6) | (bytes[++i] & 0x3f))
} else if (b < 0xf0 && i + 2 < bytes.length) {
result += String.fromCharCode(
((b & 0x0f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f),
)
} else if (i + 3 < bytes.length) {
const cp =
((b & 0x07) << 18) |
((bytes[++i] & 0x3f) << 12) |
((bytes[++i] & 0x3f) << 6) |
(bytes[++i] & 0x3f)
result += String.fromCodePoint(cp)
}
}
return result
}
} as unknown as typeof TextDecoder
}
export {}
+201
View File
@@ -0,0 +1,201 @@
import { Wallet, xrpToDrops, dropsToXrp, isValidClassicAddress } from 'xrpl'
import { getItem, setItem, removeItem } from './storage'
const RPC_URL = 'https://s1.ripple.com:51234/'
const RIPPLE_EPOCH = 946684800
const WALLET_KEY = 'koins_xrp_wallet'
const SEED_KEY = 'koins_xrp_seed'
export interface XrpWalletInfo {
id: string
name: string
address: string
createdAt: string
}
export interface XrpBalance {
balance: string
funded: boolean
}
export interface XrpTxEntry {
hash: string
amount: string
timestamp: number
direction: 'in' | 'out'
fee?: string
confirmed: boolean
destinationTag?: number
}
async function rpc(command: string, params: Record<string, unknown>): Promise<any> {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: command,
params: [params],
id: 1,
}),
})
if (!res.ok) throw new Error(`RPC ${command} failed: HTTP ${res.status}`)
const data = await res.json()
const err = data?.result?.error ?? data?.error
if (err) {
const msg = data?.result?.error_message ?? data?.error_message ?? err
const e = new Error(String(msg))
;(e as any).code = err
throw e
}
return data.result
}
export function isValidAddress(address: string): boolean {
return isValidClassicAddress(address)
}
export function generateWallet(): { address: string; seed: string } {
const wallet = Wallet.generate()
return { address: wallet.classicAddress, seed: wallet.seed! }
}
export function walletFromSecret(secret: string): { address: string } {
const s = secret.trim()
try {
return { address: Wallet.fromSeed(s).classicAddress }
} catch {
return { address: Wallet.fromMnemonic(s).classicAddress }
}
}
export function walletForSigning(secret: string): Wallet {
const s = secret.trim()
try {
return Wallet.fromSeed(s)
} catch {
return Wallet.fromMnemonic(s)
}
}
export function getWallet(): XrpWalletInfo | null {
const raw = getItem(WALLET_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as XrpWalletInfo
} catch {
return null
}
}
export function saveWallet(wallet: XrpWalletInfo): void {
setItem(WALLET_KEY, JSON.stringify(wallet))
}
export function deleteWallet(): void {
removeItem(WALLET_KEY)
removeItem(SEED_KEY)
}
export function getSeed(): string | null {
return getItem(SEED_KEY)
}
export function saveSeed(seed: string): void {
setItem(SEED_KEY, seed)
}
export async function getBalance(address: string): Promise<XrpBalance> {
try {
const result = await rpc('account_info', {
account: address,
ledger_index: 'current',
})
const balance = (result.account_data?.Balance ?? '0') as string
return { balance: dropsAmount(balance), funded: true }
} catch (e: any) {
if (e?.code === 'actNotFound') {
return { balance: '0', funded: false }
}
throw e
}
}
function dropsAmount(value: unknown): string {
if (typeof value !== 'string') return '0'
return String(dropsToXrp(value))
}
export async function getTransactions(address: string): Promise<XrpTxEntry[]> {
const result = await rpc('account_tx', {
account: address,
ledger_index_min: -1,
ledger_index_max: -1,
limit: 25,
})
const entries: XrpTxEntry[] = []
for (const entry of result.transactions as any[]) {
const tx = entry.tx_json ?? entry.tx
if (!tx || tx.TransactionType !== 'Payment') continue
const meta = entry.meta ?? entry.metaData ?? {}
if (meta.TransactionResult && meta.TransactionResult !== 'tesSUCCESS') continue
const delivered = meta.delivered_amount ?? meta.DeliveredAmount ?? tx.Amount
entries.push({
hash: entry.hash ?? tx.hash,
amount: dropsAmount(delivered),
timestamp: tx.date ? tx.date + RIPPLE_EPOCH : 0,
direction: tx.Destination === address ? 'in' : 'out',
fee: dropsAmount(tx.Fee),
confirmed: entry.validated === true,
destinationTag: tx.DestinationTag,
})
}
return entries
}
export async function sendPayment(
secret: string,
to: string,
amount: string,
destinationTag?: number,
): Promise<{ hash: string; fee: string }> {
if (!isValidClassicAddress(to)) throw new Error('Invalid XRP address')
const wallet = walletForSigning(secret)
const account = wallet.classicAddress
const [info, feeResult] = await Promise.all([
rpc('account_info', { account, ledger_index: 'current' }),
rpc('fee', {}),
])
const accountData = info.account_data ?? {}
const sequence = accountData.Sequence as number
const balanceDrops = BigInt(accountData.Balance ?? '0')
const feeDrops = BigInt(feeResult.drops?.open_ledger_fee ?? '10')
const amountDrops = BigInt(xrpToDrops(amount))
if (amountDrops + feeDrops > balanceDrops) {
throw new Error('Insufficient balance (includes network fee)')
}
const tx: Record<string, unknown> = {
TransactionType: 'Payment',
Account: account,
Destination: to,
Amount: xrpToDrops(amount),
Fee: feeDrops.toString(),
Sequence: sequence,
Flags: 0,
}
if (destinationTag !== undefined) tx.DestinationTag = destinationTag
const signed = wallet.sign(tx)
const result = await rpc('submit', { tx_blob: signed.tx_blob })
const engineResult = result.engine_result
if (engineResult !== 'tesSUCCESS') {
const msg = result.engine_result_message ?? 'Transaction failed'
throw new Error(`${engineResult}: ${msg}`)
}
return {
hash: signed.hash,
fee: dropsAmount(feeDrops.toString()),
}
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="@lynx-js/rspeedy/client" />
declare module '@lynx-js/types' {
interface GlobalProps {
/**
* Define your global properties in this interface.
* These types will be accessible through `lynx.__globalProps`.
*/
}
}
export {}
+11
View File
@@ -0,0 +1,11 @@
declare module '*.vue' {
import type { Component } from 'vue-lynx'
const component: Component
export default component
}
declare module '*.png' {
const src: string
export default src
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["vue-lynx/types"],
"noEmit": true,
},
"vueCompilerOptions": {
"plugins": ["vue-lynx/types/volar-plugin"]
},
"include": ["./**/*.ts", "./**/*.vue"],
}
+32
View File
@@ -0,0 +1,32 @@
import type { Config } from 'tailwindcss'
import preset from '@lynx-js/tailwind-preset'
const config: Config = {
content: ['./src/**/*.{vue,js,ts}'],
presets: [preset],
theme: {
extend: {
colors: {
background: '#0a0a0b',
foreground: '#fafafa',
muted: {
DEFAULT: '#a1a1aa',
foreground: '#71717a',
},
card: {
DEFAULT: '#18181b',
foreground: '#fafafa',
},
border: '#27272a',
primary: {
DEFAULT: '#3b82f6',
foreground: '#ffffff',
},
destructive: '#f87171',
success: '#34d399',
},
},
},
}
export default config
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"skipLibCheck": true,
},
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./src" },
],
"files": [],
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
// Rspeedy uses Node.js's module resolution
"module": "node16",
"moduleResolution": "node16",
"erasableSyntaxOnly": true,
// Node.js 18+
"lib": ["es2023"],
"target": "es2022",
"noEmit": true,
},
"include": ["./lynx.config.ts"],
}
+3017 -13
View File
File diff suppressed because it is too large Load Diff