灵符lingfu
L

ling-wallet

↓ 0 downloads

Ling Wallet: a post-quantum privacy-coin wallet, full node and HyperTorus miner for LNG

Windows · x86-64 ✕Linux · x86-64 ✕LingOS · x86-64LingOS · Pi 5 ARM64 ✕
Keywords wallet, cryptocurrency, lng, node, miner, ling
lingfu install ling-wallet
LicenseMIT
Authorsling-lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (116)

ling/real_backend.ling
# real_backend.ling — the live backend behind the be_* contract
# (docs/LING_BACKEND.md). Runs on the UI (main) thread: it spawns the
# isolates, owns the channels, caches their status pushes in the st_* store,
# and answers every be_* call without blocking (except the few calls that
# must wait for a password check: create / restore / unlock / reveal /
# change password / confirm).
#
#   miners ×N  (no capabilities)        ── found / hashstat ──▶ node
#   node       (db, fs, p2p)            ── templates/cfg ─────▶ miners
#   wallet     (keys, db, fs)           ◀─ tip / submitted ──── node
#   all of them ── status, replies ────▶ ui (this thread)

use "core/params.ling"

bind ABOUT_ROWS = [
    ["Identity signatures", "standard", "ML-DSA-87 (FIPS 204) signs address bundles and peer handshakes."],
    ["Key exchange", "standard", "X25519 combined with ML-KEM-1024 (FIPS 203); secure if either holds."],
    ["Encryption", "standard", "XChaCha20-Poly1305 for the wallet file, memos and network frames."],
    ["Password hashing", "standard", "Argon2id, 256 MiB, 3 passes, for the wallet file key."],
    ["Hashing", "standard", "SHAKE256 (SHA-3) with domain separation for every hash."],
    ["Network wire layer", "novel", "DICE-42 cascade around the encrypted frames; defense in depth only."],
    ["Ring signatures", "experimental", "LRS-87 lattice linkable ring signatures, 11 members; unaudited."],
    ["Proof of work", "novel", "HyperTorus memory-hard walk (16 MiB); ends in SHAKE256."],
    ["Network privacy", "standard", "Dandelion++ relay, padded frames, cover traffic, optional Tor (SOCKS5)."],
    ["Limitation", "known", "Whoever paid you can tell when you spend those exact coins. Send them to yourself once to break the link."],
    ["Limitation", "known", "Amounts use fixed denominations; per-output amounts are visible."],
    ["Limitation", "known", "Small network: rings draw from few outputs until more people use LNG."]
]

fn real_now() {
    return floor(epoch_now())
}

fn arg_list(name) {
    bind args = get_args()
    bind out = []
    bind i = 0
    while i + 1 < len(args) {
        if list_get(args, i) == name {
            bind out = list_push(out, list_get(args, i + 1))
        }
        bind i = i + 1
    }
    return out
}

fn real_init() {
    bind data_dir = cli_arg("data-dir", default_data_dir())
    # every interface by default, so other machines can reach this node; `--listen none` takes no inbound connections
    bind proxy = cli_arg("proxy", "")
    # Default: listen on every interface. With a proxy (Tor), default to NO inbound listener, so a clearnet socket can't
    # be linked to the Tor-routed node. --listen none turns inbound off; an explicit --listen overrides either default.
    bind listen = cli_arg("listen", if proxy == "" { "0.0.0.0:" + num_str(NET_PORT) } else { "none" })
    if listen == "none" { bind listen = "" }
    bind peers = arg_list("--peer")
    # --fast-kdf: cheap Argon2 for testing only, and only on a folder named with --data-dir: it can never reach the
    # real wallet in the default folder (and a real wallet unlocked once with strong settings is re-encrypted with them)
    bind fast = cli_arg("fast-kdf", "") != "" && cli_arg("data-dir", "") != ""
    if cli_arg("fast-kdf", "") != "" && !fast { println("ling-wallet: --fast-kdf ignored: it needs an explicit --data-dir") }
    bind m_kib = if fast { 64 } else { 262144 }
    bind t_cost = if fast { 1 } else { 3 }
    make_dir(data_dir)
    install_seeds(data_dir)

    bind ui = chan_new(1024)
    bind node_in = chan_new(1024)
    # the node's notices to the wallet (tip, submitted, ...) and the UI's requests (passwords, phrases) on separate
    # channels: the wallet takes requests only from the UI, and the UI's channel wipes what passes through it
    bind wallet_in = chan_new(256)
    bind wallet_ui = chan_new_secure(64)
    st_set("ui_send", list_get(ui, 0))
    st_set("ui_recv", list_get(ui, 1))
    st_set("node_send", list_get(node_in, 0))
    st_set("wallet_send", list_get(wallet_ui, 0))

    # Mining is off by default, so don't spawn a miner isolate per core up front —
    # that was one idle interpreter thread per CPU (32 on a 32-core box), each
    # parked on a channel wait, holding memory for nothing. Create every control
    # channel now so the node can address all potential miners, but keep the
    # receiver ends and spawn the isolates lazily in ensure_miners(), the first
    # time mining is turned on and only as many as the chosen thread count.
    bind cpus = thread_cpus()
    bind miners = []
    bind recvs = []
    bind i = 0
    while i < cpus {
        bind mc = chan_new(16)
        bind miners = list_push(miners, list_get(mc, 0))
        bind recvs = list_push(recvs, list_get(mc, 1))
        bind i = i + 1
    }
    st_set("miner_recv", recvs)
    st_set("miners_spawned", 0)
    st_set("max_threads", cpus)
    thread_spawn("isolates/node.ling", "node_main", [data_dir, list_get(node_in, 1), list_get(ui, 0), list_get(wallet_in, 0), miners, listen, peers, proxy], ["db", "fs", "p2p"])
    thread_spawn("isolates/walletd.ling", "wallet_main", [data_dir, list_get(wallet_in, 1), list_get(ui, 0), list_get(node_in, 0), m_kib, t_cost, list_get(wallet_ui, 1)], ["keys", "db", "fs"])

    st_set("w_state", "nowallet")
    st_set("next_req", 1)
    st_set("hashrate", 0)
    st_set("trace", [])
    # Mining preferences survive restarts (node keeps them in its kv table
    # too; this is the UI's copy).
    st_set("m_enabled", false)
    st_set("m_threads", 1)
    st_set("m_intensity", 35)
    st_set("m_pause", true)
    st_set("m_paused_reason", "")
    return 0
}

# %APPDATA%/LingWallet/mainnet. A wallet from before the rename (…/testnet) is moved there once; if that folder is in use
# (another wallet running on it), this start keeps using it and the move happens next time.
fn default_data_dir() {
    bind root = env_get("APPDATA", ".") + "/LingWallet"
    bind dir = root + "/mainnet"
    bind old = root + "/testnet"
    if !file_exists(dir) && file_exists(old) {
        rename_file(old, dir)
        if !file_exists(dir) { return old }
    }
    return dir
}

# The seed list shipped with the wallet (ling/assets/seeds.lwseeds) goes into the data folder when that has none, or an
# older one, so a fresh install can find the network. A list put there by hand that is newer stays.
fn install_seeds(data_dir) {
    bind shipped = ""
    bind cands = ["assets/seeds.lwseeds", "ling/assets/seeds.lwseeds"]
    bind i = 0
    while i < len(cands) {
        if shipped == "" && file_exists(list_get(cands, i)) { bind shipped = list_get(cands, i) }
        bind i = i + 1
    }
    if shipped == "" { return () }
    bind fresh = match file_read_hex(shipped) {
        ok(v) => v,
        bad(e) => "",
    }
    bind fresh_at = seeds_issued(fresh)
    if fresh_at < 0 { return () }
    bind dest = data_dir + "/seeds.lwseeds"
    if file_exists(dest) {
        bind cur = match file_read_hex(dest) {
            ok(v) => v,
            bad(e) => "",
        }
        if seeds_issued(cur) >= fresh_at { return () }
    }
    file_write_hex(dest, fresh)
}

# When a seed list was signed (unix seconds), or -1 when it is not a valid list for this network's authority.
fn seeds_issued(hex) {
    if hex == "" { return 0 - 1 }
    return match p2p_seeds_verify(hex, seed_authority_hex()) {
        ok(info) => sel_issued(info),
        bad(e) => 0 - 1,
    }
}
fn sel_issued(info) {
    if list_get(info, 0) != NET_ID { return 0 - 1 }
    return list_get(info, 1)
}

# Drain everything the isolates pushed since the last frame.
fn real_tick() {
    bind n = 0
    while n < 128 {
        bind m = match chan_try_recv(st_get("ui_recv", "")) {
            ok(v) => v,
            bad(e) => (),
        }
        if m == () {
            bind n = 128
        } else {
            on_push(m)
            bind n = n + 1
        }
    }
    apply_pause_policy()
    return 0
}

fn on_push(m) {
    bind kind = list_get(m, 0)
    if kind == "wallet_status" {
        # After a lock, statuses the wallet sent before it processed the
        # lock may still be queued; never let them flip us back to unlocked.
        if st_get("locking", 0) == 1 {
            if list_get(m, 1) == "unlocked" {
                return 0
            }
            st_set("locking", 0)
        }
        st_set("w_state", list_get(m, 1))
        if list_get(m, 1) == "unlocked" {
            st_set("w_unlocked", list_get(m, 2))
            st_set("w_pending", list_get(m, 3))
            st_set("w_short", list_get(m, 4))
            st_set("w_hash", list_get(m, 5))
            st_set("w_scanned", list_get(m, 6))
            st_set("w_tip", list_get(m, 7))
            st_set("w_history", list_get(m, 8))
            st_set("w_export", list_get(m, 9))
        }
        return 0
    }
    if kind == "node_status" {
        st_set("n_height", list_get(m, 1))
        st_set("n_best", list_get(m, 2))
        st_set("n_difficulty", list_get(m, 3))
        st_set("n_mempool", list_get(m, 4))
        st_set("n_peers", list_get(m, 5))
        st_set("n_route", list_get(m, 6))
        st_set("n_seeds", list_get(m, 7))
        st_set("n_audit", list_get(m, 8))
        # hashes since the previous push (~1 s); smooth it.
        bind inst = list_get(m, 9)
        st_set("hashrate", st_get("hashrate", 0) * 0.7 + inst * 0.3)
        st_set("blocks_found", list_get(m, 10))
        return 0
    }
    if kind == "trace" {
        st_set("trace", list_get(m, 1))
        return 0
    }
    if kind == "reply" {
        st_set(format("rep/{}", list_get(m, 1)), [list_get(m, 2)])
        return 0
    }
    if kind == "progress" {
        st_set(format("prog/{}", list_get(m, 1)), list_get(m, 2))
        return 0
    }
    if kind == "fatal" {
        st_set("fatal", list_get(m, 1))
        print("ling-wallet: ", list_get(m, 1))
        return 0
    }
    return 0
}

# Send a request to the wallet isolate and wait (pumping other pushes) for
# its reply. Used only for the password-gated calls.
fn wallet_call(msg_tail, timeout_s) {
    bind id = st_get("next_req", 1)
    st_set("next_req", id + 1)
    bind msg = [list_get(msg_tail, 0), id]
    bind i = 1
    while i < len(msg_tail) {
        bind msg = list_push(msg, list_get(msg_tail, i))
        bind i = i + 1
    }
    chan_send(st_get("wallet_send", ""), msg)
    bind key = format("rep/{}", id)
    bind deadline = epoch_now() + timeout_s
    while st_has(key) == 0 && epoch_now() < deadline {
        match chan_recv_timeout(st_get("ui_recv", ""), 50) {
            ok(m) => on_push(m),
            bad(e) => 0,
        }
    }
    if st_has(key) == 0 {
        return bad("The wallet didn't answer in time.")
    }
    bind r = list_get(st_get(key, []), 0)
    # replies can carry the 24 words: not kept in the store once read
    st_set(key, [()])
    return r
}

# Fire-and-poll request (lookups): returns the last answer for `tag`.
fn wallet_ask(tag, msg_tail) {
    bind id = st_get("next_req", 1)
    st_set("next_req", id + 1)
    bind msg = [list_get(msg_tail, 0), id]
    bind i = 1
    while i < len(msg_tail) {
        bind msg = list_push(msg, list_get(msg_tail, i))
        bind i = i + 1
    }
    chan_try_send(st_get("wallet_send", ""), msg)
    st_set("ask/" + tag, id)
    return 0
}

# ── the be_* contract ─────────────────────────────────────────────────────

fn real_state() {
    return st_get("w_state", "nowallet")
}

fn real_create(password) {
    st_set("locking", 0)
    bind r = wallet_call(["create", password], 30)
    return r
}

fn real_restore(phrase, password) {
    st_set("locking", 0)
    return wallet_call(["restore", phrase, password], 30)
}

fn real_unlock(password) {
    st_set("locking", 0)
    bind r = wallet_call(["unlock", password], 30)
    match r {
        ok(v) => st_set("w_state", "unlocked"),
        bad(e) => 0,
    }
    return r
}

fn real_lock() {
    st_set("locking", 1)
    chan_try_send(st_get("wallet_send", ""), ["lock"])
    st_set("w_state", "locked")
    st_set("w_history", [])
    return 0
}

fn real_summary() {
    if st_get("w_state", "") != "unlocked" {
        return bad("locked")
    }
    bind peers = len(st_get("n_peers", []))
    bind tip = st_get("n_height", 0)
    bind best = st_get("n_best", 0)
    bind scanned = st_get("w_scanned", 0)
    bind kind = "synced"
    bind h = tip
    bind target = tip
    if peers == 0 && tip == 0 {
        bind kind = "connecting"
    } else {
        if best > tip {
            bind kind = "syncing"
            bind target = best
        } else {
            if scanned < tip {
                bind kind = "syncing"
                bind h = scanned
            }
        }
    }
    return ok(Summary(st_get("w_unlocked", 0), st_get("w_pending", 0), st_get("w_short", ""), st_get("w_hash", ""), kind, h, target, NET_NAME))
}

fn real_history(limit) {
    bind rows = sort_history(st_get("w_history", []))
    bind out = []
    bind i = 0
    while i < len(rows) && i < limit {
        bind r = list_get(rows, i)
        bind out = list_push(out, TxRecord(list_get(r, 0), list_get(r, 1), list_get(r, 2), list_get(r, 3), list_get(r, 4), list_get(r, 5), list_get(r, 6), list_get(r, 7), list_get(r, 8), list_get(r, 9)))
        bind i = i + 1
    }
    return out
}

# Newest first; mempool entries (height -1) on top.
fn hist_key(r) {
    bind h = list_get(r, 4)
    if h == -1 {
        return 9000000000000
    }
    return h
}

fn sort_history(rows) {
    bind out = []
    bind i = 0
    while i < len(rows) {
        bind v = list_get(rows, i)
        bind tmp = []
        bind placed = false
        bind k = 0
        while k < len(out) {
            bind w = list_get(out, k)
            if placed == false && hist_key(v) > hist_key(w) {
                bind tmp = list_push(tmp, v)
                bind placed = true
            }
            bind tmp = list_push(tmp, w)
            bind k = k + 1
        }
        if placed == false {
            bind tmp = list_push(tmp, v)
        }
        bind out = tmp
        bind i = i + 1
    }
    return out
}

fn real_lookup(address) {
    bind tag = "lookup:" + address
    if st_has("ask/" + tag) == 0 || real_now() - st_get("asked/" + tag, 0) >= 1 {
        st_set("asked/" + tag, real_now())
        wallet_ask(tag, ["lookup", address])
    }
    bind id = st_get("ask/" + tag, 0)
    bind r = st_get(format("rep/{}", id), ())
    if r == () {
        bind prev = st_get("last/" + tag, ())
        if prev == () {
            return ["pending"]
        }
        return prev
    }
    bind ans = list_get(r, 0)
    st_set("last/" + tag, ans)
    return ans
}

fn real_prepare(to, amount, memo) {
    bind r = wallet_call(["prepare", to, amount, memo], 30)
    return match r {
        ok(p) => ok(Prepared(list_get(p, 0), list_get(p, 1), list_get(p, 2), list_get(p, 3), list_get(p, 4), list_get(p, 5), list_get(p, 6), list_get(p, 7), list_get(p, 8), list_get(p, 9))),
        bad(e) => r,
    }
}

fn real_confirm(id, password) {
    return wallet_call(["confirm", id, password], 60)
}

fn real_progress(id) {
    return st_get(format("prog/{}", id), ["none"])
}

fn real_export_bundle() {
    if st_get("w_state", "") != "unlocked" {
        return ""
    }
    return st_get("w_export", "")
}

fn real_reveal(password) {
    return wallet_call(["reveal", password], 30)
}

fn real_change_password(old, new) {
    return wallet_call(["change_pw", old, new], 60)
}

fn real_mining() {
    bind paused = st_get("m_paused_reason", "")
    return Mining(st_get("m_enabled", false), st_get("m_threads", 1), st_get("max_threads", 1), st_get("m_intensity", 35), st_get("hashrate", 0), st_get("blocks_found", 0), st_get("n_difficulty", "0"), st_get("trace", []), st_get("m_pause", true), paused)
}

# Spawn miner isolates on demand, up to `k` threads. Called the first time mining
# turns on (and again if the thread count grows). Spawned miners persist — when
# inactive they park on a 500 ms channel wait, costing almost nothing — so we never
# spawn more than the high-water-mark thread count, and a wallet that never mines
# spawns none at all. The node already holds every control channel's sender end.
fn ensure_miners(k) {
    bind recvs = st_get("miner_recv", [])
    bind want = k
    if want > len(recvs) { bind want = len(recvs) }
    bind spawned = st_get("miners_spawned", 0)
    while spawned < want {
        thread_spawn("isolates/miner.ling", "miner_main", [spawned, list_get(recvs, spawned), st_get("node_send", ""), st_get("ui_send", "")], [])
        bind spawned = spawned + 1
    }
    st_set("miners_spawned", spawned)
    return 0
}

fn send_mining_cfg() {
    bind effective = st_get("m_enabled", false) && st_get("m_paused_reason", "") == ""
    bind e = if effective { 1 } else { 0 }
    bind p = if st_get("m_pause", true) { 1 } else { 0 }
    chan_try_send(st_get("node_send", ""), ["mining_cfg", e, st_get("m_threads", 1), st_get("m_intensity", 35), p])
    return 0
}

fn real_set_mining(enabled, threads, intensity, pause) {
    st_set("m_enabled", enabled)
    st_set("m_threads", threads)
    st_set("m_intensity", intensity)
    st_set("m_pause", pause)
    # Bring the miner isolates into existence the first time they're needed (and
    # when the thread count grows); send_mining_cfg then activates the first
    # `threads` of them via the node.
    if enabled { ensure_miners(threads) }
    send_mining_cfg()
    return 0
}

# Pause while the computer is in use (no input for < 60 s) or on battery.
fn apply_pause_policy() {
    if real_now() - st_get("pause_checked", 0) < 2 {
        return 0
    }
    st_set("pause_checked", real_now())
    bind reason = ""
    if st_get("m_pause", true) {
        if sys_on_battery() {
            bind reason = "On battery"
        } else {
            if sys_idle_secs() < 60 {
                bind reason = "You're using the computer"
            }
        }
    }
    if reason != st_get("m_paused_reason", "") {
        st_set("m_paused_reason", reason)
        send_mining_cfg()
    }
    return 0
}

fn real_network() {
    bind peers = st_get("n_peers", [])
    bind rows = []
    bind i = 0
    while i < len(peers) {
        bind p = list_get(peers, i)
        # p2p_peers rows: [peer_id, addr, inbound, fingerprint, is_seed, connected_secs]
        bind rows = list_push(rows, [list_get(p, 1), list_get(p, 2), list_get(p, 3), list_get(p, 4), list_get(p, 5)])
        bind i = i + 1
    }
    bind seeds = st_get("n_seeds", 0) == 1
    return NetStatus(rows, st_get("n_route", "Direct"), seeds, st_get("n_height", 0), st_get("n_difficulty", "0"), st_get("n_mempool", 0), "")
}

fn real_audit() {
    return st_get("n_audit", [])
}

fn real_about() {
    return ABOUT_ROWS
}

← Back to index