add vue lynx mobile wallet
This commit is contained in:
@@ -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