add vue lynx mobile wallet

This commit is contained in:
2026-08-09 18:09:21 +08:00
parent d15152a013
commit babd85e614
20 changed files with 4022 additions and 12 deletions
+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"],
}
+3013 -12
View File
File diff suppressed because it is too large Load Diff