Compare commits
5
Commits
v0.0.11
...
d80b58b704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80b58b704 | ||
|
|
babd85e614 | ||
|
|
d15152a013 | ||
|
|
3913889a22 | ||
|
|
da0c56658b |
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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").
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
app: {
|
||||
name: 'koins',
|
||||
identifier: 'koins.tzgyn.com',
|
||||
version: '0.0.11',
|
||||
version: '0.0.12',
|
||||
},
|
||||
build: {
|
||||
// Vite builds to dist/, we copy from there
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"d3-shape": "^3.2.0",
|
||||
"electrobun": "^1.18.1",
|
||||
"env-paths": "^4.0.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"monero-ts": "^0.11.8",
|
||||
"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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||
import Loader from '$lib/components/loader.svelte'
|
||||
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)
|
||||
|
||||
@@ -43,6 +46,33 @@
|
||||
|
||||
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 hasXmr = $derived(moneroWallet.wallets.length > 0)
|
||||
const noWallets = $derived(!hasXrp && !hasXmr)
|
||||
@@ -69,6 +99,8 @@
|
||||
maximumFractionDigits: dp,
|
||||
})
|
||||
|
||||
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
|
||||
|
||||
onMount(() => {
|
||||
frontendLog('dashboard mounted')
|
||||
})
|
||||
@@ -143,7 +175,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center pt-4 pb-2">
|
||||
<div class="flex flex-col items-center pt-4 pb-2">
|
||||
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
|
||||
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
|
||||
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
||||
@@ -154,6 +186,43 @@
|
||||
{/if}
|
||||
</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">
|
||||
<button
|
||||
onclick={() => navigate('/xrp')}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
SelectTrigger,
|
||||
} from '$lib/components/ui/select/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||
import { parseQrPayload } from '$lib/qr.js'
|
||||
|
||||
const w = moneroWallet
|
||||
|
||||
@@ -134,7 +136,15 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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
|
||||
placeholder="Monero address starting with 4..."
|
||||
bind:value={recipient} />
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
CardDescription,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||
import { parseQrPayload } from '$lib/qr.js'
|
||||
|
||||
const w = xrpWallet
|
||||
|
||||
@@ -78,7 +80,16 @@
|
||||
}}
|
||||
class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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
|
||||
placeholder="XRP address starting with r..."
|
||||
bind:value={recipient} />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Local
|
||||
.DS_Store
|
||||
*.local
|
||||
*.log*
|
||||
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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[\\/]/],
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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 {}
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./src" },
|
||||
],
|
||||
"files": [],
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
Reference in New Issue
Block a user