灵符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/isolates/walletd.ling
# walletd.ling — the wallet isolate (capabilities: keys, db, fs).
#
# The only place secret keys exist. Reads the chain database (the node
# isolate writes it) to scan for our outputs and pick ring decoys.
#
# inbox ← UI requests ["create", id, pw] … and node notices ["tip", h] …
# ui    → ["reply", id, result]  ["wallet_status", …]  ["progress", prep_id, …]
# node  → ["publish_bundle", hex] ["lookup", hash] ["submit_tx", req, hex]

use "../core/params.ling"
use "../core/codec.ling"
use "../core/money.ling"
use "../core/chain.ling"
use "../core/wallet.ling"

bind MIN_PASSWORD = 10

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

fn reply(id, result) {
    chan_try_send(st_get("ch_ui", ""), ["reply", id, result])
    return 0
}

fn to_node(msg) {
    chan_try_send(st_get("ch_node", ""), msg)
    return 0
}

# inbox: the node's notices; requests: the UI's requests (create, unlock, confirm ...), on a channel only the UI holds
fn wallet_main(data_dir, inbox, ui, node, m_kib, t_cost, requests) {
    st_set("lw_core_dir", "../core")
    st_set("ch_ui", ui)
    st_set("ch_node", node)
    st_set("path", data_dir + "/wallet.lw")
    st_set("db_path", data_dir + "/chain.db")
    st_set("db_open", 0)
    st_set("m_kib", m_kib)
    st_set("t_cost", t_cost)
    st_set("unlocked", 0)
    st_set("tip", 0)
    st_set("last_push", 0)
    st_set("next_prep", 1)
    st_set("running", 1)
    scan_reset()
    while st_get("running", 0) == 1 {
        match chan_recv_timeout(requests, 50) {
            ok(m) => on_msg(m),
            bad(e) => 0,
        }
        match chan_recv_timeout(inbox, 200) {
            ok(m) => on_node(m),
            bad(e) => 0,
        }
        if st_get("unlocked", 0) == 1 && st_get("db_open", 0) == 1 {
            scan_sync(50)
        }
        if now() - st_get("last_push", 0) >= 1 {
            st_set("last_push", now())
            push_status()
        }
    }
    keys_drop()
    return ok(())
}

fn ensure_db() {
    if st_get("db_open", 0) == 0 {
        db_open(st_get("db_path", ""))
        # Same chain.db the node opened (it set journal_mode=WAL, which is DB-wide).
        # This is the wallet's own read-heavy connection — scanning outputs for the
        # balance and for ring decoys — so give it the matching 8 MiB cache and
        # synchronous=NORMAL rather than the sqlite defaults (2 MiB / FULL).
        db_exec("PRAGMA synchronous=NORMAL")
        db_exec("PRAGMA cache_size=-8000")
        st_set("db_open", 1)
    }
    return 0
}

fn state() {
    if st_get("unlocked", 0) == 1 {
        return "unlocked"
    }
    if file_exists(st_get("path", "")) {
        return "locked"
    }
    return "nowallet"
}

fn check_new_password(pw) {
    if len(pw) < MIN_PASSWORD {
        return format("Use a password of at least {} characters.", MIN_PASSWORD)
    }
    if distinct_chars(pw) < 5 {
        return "Use a password with more variety (at least 5 different characters)."
    }
    return ""
}

fn distinct_chars(s) {
    bind seen = ""
    bind i = 0
    while i < str_len(s) {
        bind c = substr(s, i, 1)
        if str_find(seen, c) < 0 { bind seen = seen + c }
        bind i = i + 1
    }
    return str_len(seen)
}

fn open_session(master, birth, notes) {
    keys_load(master)
    st_set("birth", birth)
    st_set("notes", notes)
    st_set("unlocked", 1)
    scan_reset()
    to_node(["publish_bundle", st_get("k_bundle", "")])
    return 0
}

fn save_wallet(pw) {
    return wallet_save(st_get("path", ""), pw, st_get("k_master", ""), st_get("birth", 0), st_get("notes", []), st_get("m_kib", 262144), st_get("t_cost", 3))
}

# From the node only notices are taken: it can never ask for a restore, a reveal, a password check or a signature.
fn on_node(m) {
    bind kind = list_get(m, 0)
    if kind == "tip" || kind == "submitted" || kind == "bundle_arrived" || kind == "in_pool" {
        return on_msg(m)
    }
    return 0
}

fn on_msg(m) {
    bind kind = list_get(m, 0)
    if kind == "tip" {
        st_set("tip", list_get(m, 1))
        ensure_db()
        return 0
    }
    if kind == "create" {
        bind id = list_get(m, 1)
        bind pw = list_get(m, 2)
        bind err = check_new_password(pw)
        if err != "" {
            return reply(id, bad(err))
        }
        if file_exists(st_get("path", "")) {
            return reply(id, bad("A wallet already exists here."))
        }
        bind master = lw_random(32)
        keys_load(master)
        st_set("birth", st_get("tip", 0))
        st_set("notes", [])
        bind saved = save_wallet(pw)
        bind serr = match saved {
            ok(v) => "",
            bad(e) => e,
        }
        if serr != "" {
            keys_drop()
            return reply(id, bad(serr))
        }
        open_session(master, st_get("tip", 0), [])
        return reply(id, ok(lw_mnemonic_from(master)))
    }
    if kind == "restore" {
        bind id = list_get(m, 1)
        bind err = check_new_password(list_get(m, 3))
        if err != "" {
            return reply(id, bad(err))
        }
        bind master = match lw_mnemonic_to(list_get(m, 2)) {
            ok(v) => v,
            bad(e) => "",
        }
        if master == "" {
            return reply(id, bad("That recovery phrase isn't valid. Check each word and the order."))
        }
        # never replace a wallet that is already here (only the UI's own restore, on an empty folder, gets this far)
        if file_exists(st_get("path", "")) {
            return reply(id, bad("A wallet already exists here."))
        }
        keys_load(master)
        st_set("birth", 0)
        st_set("notes", [])
        bind serr = match save_wallet(list_get(m, 3)) {
            ok(v) => "",
            bad(e) => e,
        }
        if serr != "" {
            keys_drop()
            return reply(id, bad(serr))
        }
        open_session(master, 0, [])
        return reply(id, ok(()))
    }
    if kind == "unlock" {
        bind id = list_get(m, 1)
        bind loaded = wallet_load(st_get("path", ""), list_get(m, 2))
        return match loaded {
            ok(v) => unlocked(id, v, list_get(m, 2)),
            bad(e) => reply(id, bad(e)),
        }
    }
    if kind == "lock" {
        keys_drop()
        st_set("unlocked", 0)
        st_set("notes", [])
        scan_reset()
        return 0
    }
    if kind == "reveal" {
        bind id = list_get(m, 1)
        return match wallet_load(st_get("path", ""), list_get(m, 2)) {
            ok(v) => reply(id, ok(lw_mnemonic_from(list_get(v, 0)))),
            bad(e) => reply(id, bad(e)),
        }
    }
    if kind == "change_pw" {
        bind id = list_get(m, 1)
        bind err = check_new_password(list_get(m, 3))
        if err != "" {
            return reply(id, bad(err))
        }
        return match wallet_load(st_get("path", ""), list_get(m, 2)) {
            ok(v) => reply(id, wallet_save(st_get("path", ""), list_get(m, 3), list_get(v, 0), list_get(v, 1), list_get(v, 2), st_get("m_kib", 262144), st_get("t_cost", 3))),
            bad(e) => reply(id, bad(e)),
        }
    }
    if kind == "lookup" {
        return reply(list_get(m, 1), lookup(list_get(m, 2)))
    }
    if kind == "prepare" {
        return reply(list_get(m, 1), prepare(list_get(m, 2), list_get(m, 3), list_get(m, 4)))
    }
    if kind == "confirm" {
        return reply(list_get(m, 1), confirm(list_get(m, 2), list_get(m, 3)))
    }
    if kind == "submitted" {
        return on_submitted(list_get(m, 1), list_get(m, 2), list_get(m, 3))
    }
    if kind == "bundle_arrived" {
        return 0
    }
    if kind == "shutdown" {
        st_set("running", 0)
        return 0
    }
    return 0
}

fn unlocked(id, v, pw) {
    open_session(list_get(v, 0), list_get(v, 1), list_get(v, 2))
    # a file saved with weaker Argon2 settings than this session's (an old or a test build) is re-encrypted with them
    bind kdf = wallet_kdf(st_get("path", ""))
    if list_get(kdf, 0) < st_get("m_kib", 262144) || list_get(kdf, 1) < st_get("t_cost", 3) {
        save_wallet(pw)
    }
    return reply(id, ok(()))
}

# ── recipients ────────────────────────────────────────────────────────────

# → ok(Bundle) / bad(msg)
fn resolve(to) {
    bind t = trim(to)
    if starts_with(t, "lngkey1") {
        bind r = bundle_import(t)
        return match r {
            ok(b) => checked_network(b),
            bad(e) => r,
        }
    }
    bind h = match parse_short_address(t) {
        ok(v) => v,
        bad(e) => "",
    }
    if h == "" {
        return parse_short_address(t)
    }
    ensure_db()
    bind data = bundle_get(h)
    if data == "" {
        return bad("not verified yet")
    }
    bind r = bundle_parse(data)
    return match r {
        ok(b) => matches_hash(b, h),
        bad(e) => r,
    }
}

fn checked_network(b) {
    if b.network != NET_ID {
        return bad("That address belongs to a different network.")
    }
    ensure_db()
    bundle_put(b.hash, b.raw)
    return ok(b)
}

fn matches_hash(b, h) {
    if b.hash != h || b.network != NET_ID {
        return bad("Stored address data doesn't match; refusing to use it.")
    }
    return ok(b)
}

# → ["pending"] / ["verified", short, hash, identity_fp] / ["failed", msg]
fn lookup(addr) {
    bind t = trim(addr)
    bind r = resolve(t)
    bind b = match r {
        ok(v) => v,
        bad(e) => (),
    }
    if b != () {
        return ["verified", short_address(b.hash), b.hash, lw_id_fingerprint(b.id_pk)]
    }
    if starts_with(t, "lngkey1") {
        return ["failed", r.x]
    }
    bind h = match parse_short_address(t) {
        ok(v) => v,
        bad(e) => "",
    }
    if h == "" {
        return ["failed", parse_short_address(t).x]
    }
    if st_has("lk/" + h) == 0 {
        st_set("lk/" + h, now())
        to_node(["lookup", h])
    }
    if now() - st_get("lk/" + h, now()) > 25 {
        return ["failed", "No peer could provide this address. Ask the recipient for their full lngkey1 address and paste that instead."]
    }
    return ["pending"]
}

# ── sending ───────────────────────────────────────────────────────────────

fn prepare(to, amount, memo) {
    if st_get("unlocked", 0) != 1 {
        return bad("Wallet is locked.")
    }
    if hex_len(text_hex(memo)) > MEMO_TEXT_LEN {
        return bad(format("Memos are limited to {} bytes.", MEMO_TEXT_LEN))
    }
    bind r = resolve(to)
    bind b = match r {
        ok(v) => v,
        bad(e) => (),
    }
    if b == () {
        return bad("Address not verified yet.")
    }
    bind own_hash = lw_h256("LW1/address", [hex_slice(st_get("k_bundle", ""), 0, 2 + 1600 + 2944 + 2592)])
    if b.hash == own_hash {
        return bad("That's your own address.")
    }
    bind ref = chain_tip().height
    bind pr = plan_send(ref, amount)
    bind plan = match pr {
        ok(v) => v,
        bad(e) => (),
    }
    if plan == () {
        return pr
    }
    # Will any input need a reduced (bootstrap) ring?
    bind reduced = false
    bind sel = list_get(plan, 0)
    bind i = 0
    while i < len(sel) {
        bind d = list_get(st_lget("owned", list_get(sel, i)), 0)
        if eligible_count(d, ref, RING_SIZE) <= RING_SIZE {
            bind reduced = true
        }
        bind i = i + 1
    }
    bind id = st_get("next_prep", 1)
    st_set("next_prep", id + 1)
    st_set(format("prep/{}", id), [plan, b, amount, memo, short_address(b.hash), now()])
    bind size = 32 + len(sel) * INPUT_BYTES + list_get(plan, 3) * OUTPUT_BYTES
    # Prepared { id, to, address_hash, amount, fee, inputs, outputs, size_bytes, ring_size, reduced }
    return ok([id, short_address(b.hash), b.hash, amount, list_get(plan, 1), len(sel), list_get(plan, 3), size, RING_SIZE, reduced])
}

fn progress(prep_id, state_list) {
    chan_try_send(st_get("ch_ui", ""), ["progress", prep_id, state_list])
    return 0
}

fn confirm(prep_id, pw) {
    # Re-check the password against the file before spending anything.
    bind check = wallet_load(st_get("path", ""), pw)
    bind err = match check {
        ok(v) => "",
        bad(e) => e,
    }
    if err != "" {
        return bad(err)
    }
    bind key = format("prep/{}", prep_id)
    bind p = st_get(key, ())
    if p == () {
        return bad("That send expired. Prepare it again.")
    }
    st_set(key, ())
    if now() - list_get(p, 5) > 600 {
        return bad("That send expired. Prepare it again.")
    }
    progress(prep_id, ["signing"])
    bind ref = chain_tip().height
    bind built = build_tx(list_get(p, 0), list_get(p, 1), list_get(p, 2), list_get(p, 3), ref)
    bind out = match built {
        ok(v) => v,
        bad(e) => (),
    }
    if out == () {
        progress(prep_id, ["failed", built.x])
        return ok(())
    }
    bind tx = list_get(out, 0)
    bind kids = list_get(out, 1)
    bind i = 0
    while i < len(kids) {
        st_set(own_key("pending_ki/", list_get(kids, i)), 1)
        bind i = i + 1
    }
    st_set(format("sub/{}", prep_id), [p, kids, pw, list_get(out, 2)])
    to_node(["submit_tx", prep_id, enc_tx(tx)])
    return ok(())
}

fn on_submitted(prep_id, txid, err) {
    bind s = st_get(format("sub/{}", prep_id), ())
    if s == () {
        return 0
    }
    st_set(format("sub/{}", prep_id), ())
    bind pw = list_get(s, 2)
    if err != "" {
        bind kids = list_get(s, 1)
        bind i = 0
        while i < len(kids) {
            st_set(own_key("pending_ki/", list_get(kids, i)), 0)
            bind i = i + 1
        }
        progress(prep_id, ["failed", err])
        return 0
    }
    bind p = list_get(s, 0)
    bind plan = list_get(p, 0)
    bind note = [txid, list_get(p, 4), list_get(p, 2), list_get(plan, 1), list_get(p, 3), now()]
    if st_get("unlocked", 0) == 1 {
        st_set("notes", list_push(st_get("notes", []), note))
    }
    record_note(pw, note)
    # Mark this as one of our own txs, so its change/churn outputs are never
    # treated as "received from someone else" and churned (which would loop).
    st_set(own_key("mytx/", txid), 1)
    wipe(pw)
    progress(prep_id, ["sent", txid])
    return 0
}

# The note goes into the FILE as it is now: loaded with the password this send was authorized with, the note added,
# saved again. Never from the session's memory, which a lock may have emptied (that save would write a file without
# its seed), and never undoing a password change made while the send was in flight (then the old password no longer
# opens the file, and the note is left to the history scan).
fn record_note(pw, note) {
    bind path = st_get("path", "")
    return match wallet_load(path, pw) {
        ok(v) => wallet_save(path, pw, list_get(v, 0), list_get(v, 1), list_push(list_get(v, 2), note), max(st_get("m_kib", 262144), list_get(wallet_kdf(path), 0)), max(st_get("t_cost", 3), list_get(wallet_kdf(path), 1))),
        bad(e) => bad(e),
    }
}

# ── status for the UI ─────────────────────────────────────────────────────

fn push_status() {
    bind st = state()
    if st != "unlocked" {
        chan_try_send(st_get("ch_ui", ""), ["wallet_status", st])
        return 0
    }
    ensure_db()
    bind tip = chain_tip().height
    bind bal = balance(tip)
    bind h = lw_h256("LW1/address", [hex_slice(st_get("k_bundle", ""), 0, 2 + 1600 + 2944 + 2592)])
    chan_try_send(st_get("ch_ui", ""), ["wallet_status", st, list_get(bal, 0), list_get(bal, 1), short_address(h), h, scan_height(), tip, history(tip), bundle_export(st_get("k_bundle", ""))])
    return 0
}

# TxRecord rows: [txid, direction, amount, fee, height, confirmations, timestamp, counterparty, memo, reduced]
fn history(tip) {
    bind out = []
    # Incoming / mined: our outputs in txs we didn't author.
    bind seen = format("hist{}", lw_random(6))
    bind i = 0
    while i < st_llen("owned") {
        bind o = st_lget("owned", i)
        bind txid = list_get(o, 4)
        if st_has(own_key("spend_tx/", txid)) == 0 {
            bind key = seen + txid
            if st_has(key) == 0 {
                st_set(key, len(out))
                bind dir = if list_get(o, 3) == 1 { "mined" } else { "in" }
                bind memo = list_get(o, 10)
                if memo == "mined" {
                    bind memo = ""
                }
                bind out = list_push(out, [txid, dir, list_get(o, 0), 0, list_get(o, 2), tip - list_get(o, 2) + 1, st_get(own_key("btime/", list_get(o, 2)), 0), "", memo, false])
            } else {
                bind idx = st_get(key, 0)
                bind r = list_get(out, idx)
                bind out = list_set(out, idx, list_set(r, 2, list_get(r, 2) + list_get(o, 0)))
            }
        }
        bind i = i + 1
    }
    # Outgoing: transactions that spent our outputs.
    bind notes = st_get("notes", [])
    bind k = 0
    while k < st_llen("spend_txs") {
        bind txid = st_lget("spend_txs", k)
        bind height = st_get(own_key("spend_tx/", txid), 0)
        bind spent = 0
        bind change = 0
        bind i = 0
        while i < st_llen("owned") {
            bind o = st_lget("owned", i)
            if list_get(o, 9) == txid {
                bind spent = spent + list_get(o, 0)
            }
            if list_get(o, 4) == txid {
                bind change = change + list_get(o, 0)
            }
            bind i = i + 1
        }
        bind note = find_note(notes, txid)
        bind amount = if note == () { spent - change } else { list_get(note, 2) }
        bind fee = if note == () { 0 } else { list_get(note, 3) }
        bind to = if note == () { "" } else { list_get(note, 1) }
        bind memo = if note == () { "" } else { list_get(note, 4) }
        bind out = list_push(out, [txid, "out", amount, fee, height, tip - height + 1, st_get(own_key("btime/", height), 0), to, memo, false])
        bind k = k + 1
    }
    # Sent but not yet mined.
    bind i = 0
    while i < len(notes) {
        bind n = list_get(notes, i)
        if st_has(own_key("spend_tx/", list_get(n, 0))) == 0 {
            bind out = list_push(out, [list_get(n, 0), "out", list_get(n, 2), list_get(n, 3), -1, 0, list_get(n, 5), list_get(n, 1), list_get(n, 4), false])
        }
        bind i = i + 1
    }
    return out
}

fn find_note(notes, txid) {
    bind i = 0
    while i < len(notes) {
        if list_get(list_get(notes, i), 0) == txid {
            return list_get(notes, i)
        }
        bind i = i + 1
    }
    return ()
}

← Back to index