L
ling-wallet
↓ 0 downloadsLing Wallet: a post-quantum privacy-coin wallet, full node and HyperTorus miner for LNG
Keywords wallet, cryptocurrency, lng, node, miner, ling
lingfu install ling-walletCode / 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 ()
}