From b506c9c0a16625b38c3dc3214aa93da0a22afc9b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 22:51:19 +0000 Subject: [PATCH] docs: Phase 6 wallet core implementation plan 11 tasks covering mnemonic seeds, TX extra parsing, account management, transfer storage, scanning, signing, ring selection, tx building, and wallet orchestration. TDD with complete code for every step. Co-Authored-By: Charon --- docs/plans/2026-02-20-wallet-core-plan.md | 2926 +++++++++++++++++++++ 1 file changed, 2926 insertions(+) create mode 100644 docs/plans/2026-02-20-wallet-core-plan.md diff --git a/docs/plans/2026-02-20-wallet-core-plan.md b/docs/plans/2026-02-20-wallet-core-plan.md new file mode 100644 index 0000000..c48615c --- /dev/null +++ b/docs/plans/2026-02-20-wallet-core-plan.md @@ -0,0 +1,2926 @@ +# Phase 6: Wallet Core Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Full send+receive wallet for the Lethean blockchain with interface-driven v1/v2+ extensibility. + +**Architecture:** Four core interfaces (Scanner, Signer, Builder, RingSelector) with v1 NLSAG implementations. Account keys encrypted via Argon2id+AES-256-GCM in go-store. Wallet orchestrator coordinates sync, balance, and send. + +**Tech Stack:** Go, CGo crypto bridge, go-store (SQLite KV), golang.org/x/crypto (Argon2id) + +**Design doc:** `docs/plans/2026-02-20-wallet-core-design.md` + +--- + +## Dependencies Between Tasks + +``` +Task 1 (Mnemonic) ─────────────────────┐ +Task 2 (TxExtra) ──────────┐ │ +Task 4 (Transfer) ─────────┤ │ + ├─ Task 5 (Scanner) ──┐ +Task 6 (RPC endpoints) ────┤ │ + ├─ Task 8 (Ring) ──┐ │ +Task 7 (Signer) ───────────┤ │ │ + ├─ Task 9 (Builder)│ │ +Task 3 (Account) ──────────┤ │ │ + (needs Task 1) │ │ │ + └──────────────────┴───┴─ Task 10 (Wallet) + Task 11 (Integration) +``` + +Tasks 1, 2, 4, 6, 7 can be done in any order (no inter-dependencies). + +--- + +## Task 1: Mnemonic Seed Encode/Decode + +**Files:** +- Create: `wallet/wordlist.go` +- Create: `wallet/mnemonic.go` +- Create: `wallet/mnemonic_test.go` + +**Context:** The Electrum-style mnemonic encodes a 32-byte secret key as 24 words (4 bytes → 3 words) plus a 25th checksum word. The wordlist has 1626 entries. The encoding algorithm and wordlist are in `~/Code/LetheanNetwork/blockchain/src/common/mnemonic-encoding.cpp`. + +**Step 1: Extract wordlist from C++ source** + +Run: +```bash +# Extract just the words from the C++ wordsArray +cd ~/Code/LetheanNetwork/blockchain/src/common +grep -oP '"\K[a-z]+(?=")' mnemonic-encoding.cpp | tail -n 1626 > /tmp/wordlist.txt +wc -l /tmp/wordlist.txt +``` +Expected: `1626 /tmp/wordlist.txt` + +**Step 2: Create `wallet/wordlist.go`** + +Generate from extracted words: +```bash +cd ~/Code/core/go-blockchain +mkdir -p wallet +# Script to generate Go source from wordlist +echo 'package wallet' > wallet/wordlist.go +echo '' >> wallet/wordlist.go +echo '// wordlist is the 1626-word Electrum mnemonic dictionary.' >> wallet/wordlist.go +echo '// Extracted from src/common/mnemonic-encoding.cpp in the C++ source.' >> wallet/wordlist.go +echo 'var wordlist = [1626]string{' >> wallet/wordlist.go +awk '{printf "\t\"%s\",\n", $0}' /tmp/wordlist.txt >> wallet/wordlist.go +echo '}' >> wallet/wordlist.go +``` + +Also add a reverse-lookup map at the bottom of the file: +```go +// wordIndex maps each word to its index in the wordlist. +var wordIndex map[string]int + +func init() { + wordIndex = make(map[string]int, len(wordlist)) + for i, w := range wordlist { + wordIndex[w] = i + } +} +``` + +**Step 3: Write failing tests for mnemonic** + +Create `wallet/mnemonic_test.go`: +```go +package wallet + +import ( + "strings" + "testing" +) + +func TestWordlistLength(t *testing.T) { + if len(wordlist) != 1626 { + t.Fatalf("wordlist length = %d, want 1626", len(wordlist)) + } +} + +func TestWordlistFirstLast(t *testing.T) { + if wordlist[0] != "like" { + t.Errorf("wordlist[0] = %q, want %q", wordlist[0], "like") + } + if wordlist[1625] != "weary" { + t.Errorf("wordlist[1625] = %q, want %q", wordlist[1625], "weary") + } +} + +func TestWordIndexConsistency(t *testing.T) { + for i, w := range wordlist { + idx, ok := wordIndex[w] + if !ok { + t.Fatalf("word %q not in index", w) + } + if idx != i { + t.Fatalf("wordIndex[%q] = %d, want %d", w, idx, i) + } + } +} + +func TestMnemonicRoundTrip(t *testing.T) { + // Known 32-byte key (all zeros) + var key [32]byte + phrase, err := MnemonicEncode(key[:]) + if err != nil { + t.Fatal(err) + } + words := strings.Fields(phrase) + if len(words) != 25 { + t.Fatalf("got %d words, want 25", len(words)) + } + + decoded, err := MnemonicDecode(phrase) + if err != nil { + t.Fatal(err) + } + if decoded != key { + t.Fatalf("round-trip failed: got %x, want %x", decoded, key) + } +} + +func TestMnemonicRoundTripNonZero(t *testing.T) { + var key [32]byte + for i := range key { + key[i] = byte(i * 7) + } + phrase, err := MnemonicEncode(key[:]) + if err != nil { + t.Fatal(err) + } + decoded, err := MnemonicDecode(phrase) + if err != nil { + t.Fatal(err) + } + if decoded != key { + t.Fatalf("round-trip failed: got %x, want %x", decoded, key) + } +} + +func TestMnemonicInvalidWordCount(t *testing.T) { + _, err := MnemonicDecode("like just love") + if err == nil { + t.Fatal("expected error for 3 words") + } +} + +func TestMnemonicInvalidWord(t *testing.T) { + // 25 words but one is invalid + phrase := "like just love know never want time out there make look eye down only think " + + "call hand high keep last long make new zzzznotaword" + _, err := MnemonicDecode(phrase) + if err == nil { + t.Fatal("expected error for invalid word") + } +} + +func TestMnemonicBadChecksum(t *testing.T) { + var key [32]byte + phrase, _ := MnemonicEncode(key[:]) + words := strings.Fields(phrase) + // Corrupt the checksum word + words[24] = "never" + if words[24] == words[0] { + words[24] = "want" + } + _, err := MnemonicDecode(strings.Join(words, " ")) + if err == nil { + t.Fatal("expected checksum error") + } +} + +func TestMnemonicInvalidLength(t *testing.T) { + _, err := MnemonicEncode([]byte{1, 2, 3}) + if err == nil { + t.Fatal("expected error for non-32-byte input") + } +} +``` + +**Step 4: Run tests to verify they fail** + +Run: `go test -race -run TestMnemonic -v ./wallet/` +Expected: FAIL (MnemonicEncode/MnemonicDecode not defined) + +**Step 5: Implement mnemonic encode/decode** + +Create `wallet/mnemonic.go`: +```go +package wallet + +import ( + "encoding/binary" + "fmt" + "hash/crc32" + "strings" +) + +const numWords = 1626 + +// MnemonicEncode converts a 32-byte secret key to a 25-word mnemonic phrase. +// The first 24 words encode the key (4 bytes → 3 words × 8 groups). +// The 25th word is a CRC32 checksum. +func MnemonicEncode(key []byte) (string, error) { + if len(key) != 32 { + return "", fmt.Errorf("wallet: mnemonic encode requires 32 bytes, got %d", len(key)) + } + + words := make([]string, 0, 25) + n := uint32(numWords) + + for i := 0; i < 32; i += 4 { + val := binary.LittleEndian.Uint32(key[i : i+4]) + w1 := val % n + w2 := ((val / n) + w1) % n + w3 := (((val / n) / n) + w2) % n + words = append(words, wordlist[w1], wordlist[w2], wordlist[w3]) + } + + // Checksum: CRC32 of first 3 chars of each word, pick word at index. + checkIdx := checksumIndex(words) + words = append(words, words[checkIdx]) + + return strings.Join(words, " "), nil +} + +// MnemonicDecode converts a 25-word mnemonic phrase to a 32-byte secret key. +func MnemonicDecode(phrase string) ([32]byte, error) { + var key [32]byte + words := strings.Fields(phrase) + if len(words) != 25 { + return key, fmt.Errorf("wallet: mnemonic requires 25 words, got %d", len(words)) + } + + // Verify checksum. + expected := checksumIndex(words[:24]) + if words[24] != words[expected] { + return key, fmt.Errorf("wallet: mnemonic checksum failed") + } + + n := uint32(numWords) + for i := 0; i < 8; i++ { + w1, ok1 := wordIndex[words[i*3]] + w2, ok2 := wordIndex[words[i*3+1]] + w3, ok3 := wordIndex[words[i*3+2]] + if !ok1 || !ok2 || !ok3 { + word := words[i*3] + if !ok2 { + word = words[i*3+1] + } + if !ok3 { + word = words[i*3+2] + } + return key, fmt.Errorf("wallet: unknown mnemonic word %q", word) + } + + val := uint32(w1) + + n*(((n-uint32(w1))+uint32(w2))%n) + + n*n*(((n-uint32(w2))+uint32(w3))%n) + binary.LittleEndian.PutUint32(key[i*4:i*4+4], val) + } + + return key, nil +} + +// checksumIndex computes the checksum word index from the first 24 words. +func checksumIndex(words []string) int { + var prefixes string + for _, w := range words { + if len(w) >= 3 { + prefixes += w[:3] + } else { + prefixes += w + } + } + return int(crc32.ChecksumIEEE([]byte(prefixes))) % len(words) +} +``` + +**Step 6: Run tests to verify they pass** + +Run: `go test -race -run "TestWordlist|TestMnemonic" -v ./wallet/` +Expected: PASS (all 8 tests) + +**Step 7: Commit** + +```bash +git add wallet/wordlist.go wallet/mnemonic.go wallet/mnemonic_test.go +git commit -m "feat(wallet): mnemonic seed encode/decode with Electrum wordlist" +``` + +--- + +## Task 2: TX Extra Parsing + +**Files:** +- Create: `wallet/extra.go` +- Create: `wallet/extra_test.go` + +**Context:** The wallet needs to extract three fields from the raw `tx.Extra` bytes: TX public key (tag 22, 32 bytes), unlock time (tag 14, varint), and derivation hint (tag 11, varint-length-prefixed string of 2 bytes). The raw extra is a variant vector: `[varint count][tag byte + data]...`. The `wire` package already handles raw variant vectors; here we parse the wallet-critical tags. + +**Step 1: Write failing tests** + +Create `wallet/extra_test.go`: +```go +package wallet + +import ( + "testing" + + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +func TestParseTxExtraPublicKey(t *testing.T) { + // Build extra with just a public key (tag 22 + 32 bytes). + var key types.PublicKey + for i := range key { + key[i] = byte(i + 1) + } + raw := buildTestExtra(key, 0, 0) + + extra, err := ParseTxExtra(raw) + if err != nil { + t.Fatal(err) + } + if extra.TxPublicKey != key { + t.Fatalf("tx public key mismatch: got %x", extra.TxPublicKey) + } +} + +func TestParseTxExtraUnlockTime(t *testing.T) { + var key types.PublicKey + key[0] = 0xAA + raw := buildTestExtra(key, 500, 0) + + extra, err := ParseTxExtra(raw) + if err != nil { + t.Fatal(err) + } + if extra.UnlockTime != 500 { + t.Fatalf("unlock_time = %d, want 500", extra.UnlockTime) + } +} + +func TestParseTxExtraDerivationHint(t *testing.T) { + var key types.PublicKey + key[0] = 0xBB + raw := buildTestExtra(key, 0, 0x1234) + + extra, err := ParseTxExtra(raw) + if err != nil { + t.Fatal(err) + } + if extra.DerivationHint != 0x1234 { + t.Fatalf("derivation_hint = %04x, want 1234", extra.DerivationHint) + } +} + +func TestParseTxExtraEmpty(t *testing.T) { + // Empty extra: just varint(0) + raw := wire.EncodeVarint(0) + extra, err := ParseTxExtra(raw) + if err != nil { + t.Fatal(err) + } + if !extra.TxPublicKey.IsZero() { + t.Fatal("expected zero tx public key for empty extra") + } +} + +func TestParseTxExtraPreservesRaw(t *testing.T) { + var key types.PublicKey + key[0] = 0xCC + raw := buildTestExtra(key, 100, 0x5678) + extra, err := ParseTxExtra(raw) + if err != nil { + t.Fatal(err) + } + if len(extra.Raw) != len(raw) { + t.Fatalf("raw length = %d, want %d", len(extra.Raw), len(raw)) + } +} + +func TestBuildTxExtraRoundTrip(t *testing.T) { + var key types.PublicKey + for i := range key { + key[i] = byte(i + 10) + } + raw := BuildTxExtra(key) + extra, err := ParseTxExtra(raw) + if err != nil { + t.Fatal(err) + } + if extra.TxPublicKey != key { + t.Fatalf("round-trip key mismatch") + } +} + +// buildTestExtra constructs a raw extra with the given fields. +func buildTestExtra(txPubKey types.PublicKey, unlockTime uint64, hint uint16) []byte { + var count uint64 + var elements []byte + + // Tag 22: public key (32 bytes) + if !txPubKey.IsZero() { + count++ + elements = append(elements, 22) + elements = append(elements, txPubKey[:]...) + } + + // Tag 14: unlock time (varint) + if unlockTime > 0 { + count++ + elements = append(elements, 14) + elements = append(elements, wire.EncodeVarint(unlockTime)...) + } + + // Tag 11: derivation hint (string: varint(2) + 2 bytes) + if hint > 0 { + count++ + elements = append(elements, 11) + elements = append(elements, wire.EncodeVarint(2)...) // length = 2 + elements = append(elements, byte(hint&0xFF), byte(hint>>8)) + } + + raw := wire.EncodeVarint(count) + raw = append(raw, elements...) + return raw +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestParseTxExtra -v ./wallet/` +Expected: FAIL (ParseTxExtra not defined) + +**Step 3: Implement extra parsing** + +Create `wallet/extra.go`: +```go +package wallet + +import ( + "encoding/binary" + "fmt" + + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +// Extra variant tags (wallet-critical subset). +const ( + extraTagDerivationHint = 11 + extraTagUnlockTime = 14 + extraTagPublicKey = 22 +) + +// TxExtra holds parsed wallet-critical fields from the tx extra. +type TxExtra struct { + TxPublicKey types.PublicKey + UnlockTime uint64 + DerivationHint uint16 + Raw []byte +} + +// ParseTxExtra extracts wallet-critical fields from raw extra bytes. +// Unknown tags are skipped. The Raw field preserves the original bytes. +func ParseTxExtra(raw []byte) (*TxExtra, error) { + extra := &TxExtra{Raw: make([]byte, len(raw))} + copy(extra.Raw, raw) + + if len(raw) == 0 { + return extra, nil + } + + // Read varint count. + count, n := wire.DecodeVarintBytes(raw) + if n <= 0 { + return extra, fmt.Errorf("wallet: extra: invalid varint count") + } + pos := n + + for i := uint64(0); i < count && pos < len(raw); i++ { + tag := raw[pos] + pos++ + + switch tag { + case extraTagPublicKey: + if pos+32 > len(raw) { + return extra, fmt.Errorf("wallet: extra: truncated public key") + } + copy(extra.TxPublicKey[:], raw[pos:pos+32]) + pos += 32 + + case extraTagUnlockTime: + val, vn := wire.DecodeVarintBytes(raw[pos:]) + if vn <= 0 { + return extra, fmt.Errorf("wallet: extra: invalid unlock_time varint") + } + extra.UnlockTime = val + pos += vn + + case extraTagDerivationHint: + // String-encoded: varint(length) + bytes + length, vn := wire.DecodeVarintBytes(raw[pos:]) + if vn <= 0 { + return extra, fmt.Errorf("wallet: extra: invalid hint length") + } + pos += vn + if length == 2 && pos+2 <= len(raw) { + extra.DerivationHint = binary.LittleEndian.Uint16(raw[pos : pos+2]) + } + pos += int(length) + + default: + // Skip unknown tag using wire's element-size knowledge. + skip, err := skipExtraElement(raw[pos:], tag) + if err != nil { + return extra, nil // stop parsing, return what we have + } + pos += skip + } + } + + return extra, nil +} + +// BuildTxExtra builds a raw extra containing just a TX public key. +func BuildTxExtra(txPubKey types.PublicKey) []byte { + raw := wire.EncodeVarint(1) // count = 1 + raw = append(raw, extraTagPublicKey) + raw = append(raw, txPubKey[:]...) + return raw +} + +// skipExtraElement returns the number of bytes to skip for a given tag. +func skipExtraElement(data []byte, tag uint8) (int, error) { + switch tag { + case 7, 9, 11, 19: // string types + if len(data) == 0 { + return 0, fmt.Errorf("no data") + } + length, n := wire.DecodeVarintBytes(data) + if n <= 0 { + return 0, fmt.Errorf("invalid varint") + } + return n + int(length), nil + case 14, 15, 16, 26, 27: // varint types + _, n := wire.DecodeVarintBytes(data) + if n <= 0 { + return 0, fmt.Errorf("invalid varint") + } + return n, nil + case 10: // two uint32 LE + return 8, nil + case 17, 28: // uint32 LE + return 4, nil + case 23, 24: // uint16 LE + return 2, nil + case 22: // public key + return 32, nil + case 8, 29: // two public keys + return 64, nil + default: + return 0, fmt.Errorf("unknown tag %d", tag) + } +} +``` + +This requires a `DecodeVarintBytes` helper. Check if `wire` already exports one; if not, add it. + +**Step 4: Add DecodeVarintBytes to wire package if needed** + +Check `wire/varint.go` for existing export. If not present, add to `wire/varint.go`: +```go +// DecodeVarintBytes decodes a varint from a byte slice, returning the +// value and the number of bytes consumed. Returns (0, 0) on error. +func DecodeVarintBytes(buf []byte) (uint64, int) { + var val uint64 + for i, b := range buf { + if i >= 10 { + return 0, 0 + } + val |= uint64(b&0x7F) << (7 * uint(i)) + if b&0x80 == 0 { + return val, i + 1 + } + } + return 0, 0 +} +``` + +Also add `IsZero()` to `types.PublicKey` if not already present. + +**Step 5: Run tests to verify they pass** + +Run: `go test -race -run "TestParseTxExtra|TestBuildTxExtra" -v ./wallet/` +Expected: PASS (all 6 tests) + +**Step 6: Commit** + +```bash +git add wallet/extra.go wallet/extra_test.go wire/varint.go +git commit -m "feat(wallet): TX extra parsing for wallet-critical tags" +``` + +--- + +## Task 3: Account Key Management + +**Files:** +- Create: `wallet/account.go` +- Create: `wallet/account_test.go` + +**Context:** The Account struct holds spend+view key pairs. The spend secret is the master; the view secret is `Keccak256(spend_secret)`. Persistence uses Argon2id+AES-256-GCM encryption in go-store. Depends on Task 1 (mnemonic). + +**Crypto API:** +- `crypto.GenerateKeys() (pub, sec [32]byte, err error)` +- `crypto.SecretToPublic(sec [32]byte) ([32]byte, error)` +- `crypto.FastHash(data []byte) [32]byte` (Keccak-256) + +**Step 1: Write failing tests** + +Create `wallet/account_test.go`: +```go +package wallet + +import ( + "strings" + "testing" + + store "forge.lthn.ai/core/go-store" +) + +func TestAccountGenerate(t *testing.T) { + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + // Spend and view keys should be non-zero. + var zero [32]byte + if acc.SpendSecretKey == zero { + t.Fatal("spend secret is zero") + } + if acc.ViewSecretKey == zero { + t.Fatal("view secret is zero") + } + if acc.SpendPublicKey == zero { + t.Fatal("spend public is zero") + } + if acc.ViewPublicKey == zero { + t.Fatal("view public is zero") + } +} + +func TestAccountSeedRoundTrip(t *testing.T) { + acc1, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + phrase, err := acc1.ToSeed() + if err != nil { + t.Fatal(err) + } + words := strings.Fields(phrase) + if len(words) != 25 { + t.Fatalf("seed has %d words, want 25", len(words)) + } + + acc2, err := RestoreFromSeed(phrase) + if err != nil { + t.Fatal(err) + } + if acc1.SpendSecretKey != acc2.SpendSecretKey { + t.Fatal("spend secret mismatch") + } + if acc1.ViewSecretKey != acc2.ViewSecretKey { + t.Fatal("view secret mismatch") + } + if acc1.SpendPublicKey != acc2.SpendPublicKey { + t.Fatal("spend public mismatch") + } + if acc1.ViewPublicKey != acc2.ViewPublicKey { + t.Fatal("view public mismatch") + } +} + +func TestAccountViewOnly(t *testing.T) { + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + view, err := RestoreViewOnly(acc.ViewSecretKey, acc.SpendPublicKey) + if err != nil { + t.Fatal(err) + } + if view.ViewPublicKey != acc.ViewPublicKey { + t.Fatal("view public mismatch") + } + if view.SpendPublicKey != acc.SpendPublicKey { + t.Fatal("spend public mismatch") + } + var zero [32]byte + if view.SpendSecretKey != zero { + t.Fatal("view-only should have zero spend secret") + } +} + +func TestAccountSaveLoad(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + acc1, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + password := "test-password-123" + if err := acc1.Save(s, password); err != nil { + t.Fatal(err) + } + + acc2, err := LoadAccount(s, password) + if err != nil { + t.Fatal(err) + } + if acc1.SpendSecretKey != acc2.SpendSecretKey { + t.Fatal("spend secret mismatch after load") + } + if acc1.ViewSecretKey != acc2.ViewSecretKey { + t.Fatal("view secret mismatch after load") + } +} + +func TestAccountLoadWrongPassword(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + if err := acc.Save(s, "correct"); err != nil { + t.Fatal(err) + } + + _, err = LoadAccount(s, "wrong") + if err == nil { + t.Fatal("expected error with wrong password") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestAccount -v ./wallet/` +Expected: FAIL (GenerateAccount not defined) + +**Step 3: Implement account management** + +Create `wallet/account.go`: +```go +package wallet + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "fmt" + "io" + + store "forge.lthn.ai/core/go-store" + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" + "golang.org/x/crypto/argon2" +) + +const ( + walletGroup = "wallet" + accountKey = "account" + argonTime = 3 + argonMemory = 64 * 1024 // 64 MB + argonThreads = 4 + argonKeyLen = 32 + saltLen = 16 + nonceLen = 12 // AES-GCM nonce +) + +// Account holds the wallet key material. +type Account struct { + SpendPublicKey types.PublicKey `json:"spend_public_key"` + SpendSecretKey types.SecretKey `json:"spend_secret_key"` + ViewPublicKey types.PublicKey `json:"view_public_key"` + ViewSecretKey types.SecretKey `json:"view_secret_key"` + CreatedAt uint64 `json:"created_at"` + Flags uint8 `json:"flags"` +} + +// GenerateAccount creates a new account with random keys. +// The view secret is derived deterministically from the spend secret. +func GenerateAccount() (*Account, error) { + spendPub, spendSec, err := crypto.GenerateKeys() + if err != nil { + return nil, fmt.Errorf("wallet: generate spend keys: %w", err) + } + return accountFromSpendKey(spendSec, spendPub) +} + +// RestoreFromSeed restores an account from a 25-word mnemonic. +func RestoreFromSeed(phrase string) (*Account, error) { + key, err := MnemonicDecode(phrase) + if err != nil { + return nil, fmt.Errorf("wallet: restore from seed: %w", err) + } + spendPub, err := crypto.SecretToPublic(key) + if err != nil { + return nil, fmt.Errorf("wallet: spend pub from secret: %w", err) + } + return accountFromSpendKey(key, spendPub) +} + +// RestoreViewOnly creates a view-only account (can scan, cannot spend). +func RestoreViewOnly(viewSecret types.SecretKey, spendPublic types.PublicKey) (*Account, error) { + viewPub, err := crypto.SecretToPublic([32]byte(viewSecret)) + if err != nil { + return nil, fmt.Errorf("wallet: view pub from secret: %w", err) + } + return &Account{ + SpendPublicKey: spendPublic, + ViewPublicKey: types.PublicKey(viewPub), + ViewSecretKey: viewSecret, + }, nil +} + +// ToSeed exports the spend secret key as a 25-word mnemonic. +func (a *Account) ToSeed() (string, error) { + return MnemonicEncode(a.SpendSecretKey[:]) +} + +// Address returns the standard address for this account. +func (a *Account) Address() types.Address { + return types.Address{ + SpendPublicKey: a.SpendPublicKey, + ViewPublicKey: a.ViewPublicKey, + } +} + +// Save encrypts and stores the account in go-store. +func (a *Account) Save(s *store.Store, password string) error { + plaintext, err := json.Marshal(a) + if err != nil { + return fmt.Errorf("wallet: marshal account: %w", err) + } + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return fmt.Errorf("wallet: generate salt: %w", err) + } + + derived := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + block, err := aes.NewCipher(derived) + if err != nil { + return fmt.Errorf("wallet: aes cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("wallet: gcm: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return fmt.Errorf("wallet: generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + // Store format: salt (16) + nonce (12) + ciphertext (variable) + blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) + blob = append(blob, salt...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + + encoded := fmt.Sprintf("%x", blob) + return s.Set(walletGroup, accountKey, encoded) +} + +// LoadAccount decrypts and loads an account from go-store. +func LoadAccount(s *store.Store, password string) (*Account, error) { + encoded, err := s.Get(walletGroup, accountKey) + if err != nil { + return nil, fmt.Errorf("wallet: load account: %w", err) + } + + blob, err := hexDecode(encoded) + if err != nil { + return nil, fmt.Errorf("wallet: decode account hex: %w", err) + } + + if len(blob) < saltLen+nonceLen+1 { + return nil, fmt.Errorf("wallet: account data too short") + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ciphertext := blob[saltLen+nonceLen:] + + derived := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + block, err := aes.NewCipher(derived) + if err != nil { + return nil, fmt.Errorf("wallet: aes cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("wallet: gcm: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("wallet: decrypt account: %w", err) + } + + var acc Account + if err := json.Unmarshal(plaintext, &acc); err != nil { + return nil, fmt.Errorf("wallet: unmarshal account: %w", err) + } + return &acc, nil +} + +func accountFromSpendKey(spendSec [32]byte, spendPub [32]byte) (*Account, error) { + // View secret = Keccak256(spend_secret) + viewSec := crypto.FastHash(spendSec[:]) + viewPub, err := crypto.SecretToPublic(viewSec) + if err != nil { + return nil, fmt.Errorf("wallet: view pub from secret: %w", err) + } + return &Account{ + SpendPublicKey: types.PublicKey(spendPub), + SpendSecretKey: types.SecretKey(spendSec), + ViewPublicKey: types.PublicKey(viewPub), + ViewSecretKey: types.SecretKey(viewSec), + }, nil +} + +func hexDecode(s string) ([]byte, error) { + b := make([]byte, len(s)/2) + for i := 0; i < len(s); i += 2 { + var v byte + for j := 0; j < 2; j++ { + c := s[i+j] + switch { + case c >= '0' && c <= '9': + v = v*16 + c - '0' + case c >= 'a' && c <= 'f': + v = v*16 + c - 'a' + 10 + default: + return nil, fmt.Errorf("invalid hex char %c", c) + } + } + b[i/2] = v + } + return b, nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run TestAccount -v ./wallet/` +Expected: PASS (all 5 tests) + +**Step 5: Commit** + +```bash +git add wallet/account.go wallet/account_test.go +git commit -m "feat(wallet): account key management with Argon2id encryption" +``` + +--- + +## Task 4: Transfer Type and Storage + +**Files:** +- Create: `wallet/transfer.go` +- Create: `wallet/transfer_test.go` + +**Context:** The Transfer type represents an owned output. Transfers are stored in go-store group `transfers` keyed by key image hex. Balance is computed from unspent, unlocked transfers. + +**Step 1: Write failing tests** + +Create `wallet/transfer_test.go`: +```go +package wallet + +import ( + "testing" + + store "forge.lthn.ai/core/go-store" + "forge.lthn.ai/core/go-blockchain/types" +) + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestTransferPutGet(t *testing.T) { + s := newTestStore(t) + var ki types.KeyImage + ki[0] = 0x42 + + tr := Transfer{ + TxHash: types.Hash{1}, + OutputIndex: 0, + Amount: 1000, + BlockHeight: 10, + KeyImage: ki, + } + + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + got, err := getTransfer(s, ki) + if err != nil { + t.Fatal(err) + } + if got.Amount != 1000 { + t.Fatalf("amount = %d, want 1000", got.Amount) + } + if got.KeyImage != ki { + t.Fatal("key image mismatch") + } +} + +func TestTransferMarkSpent(t *testing.T) { + s := newTestStore(t) + var ki types.KeyImage + ki[0] = 0x43 + + tr := Transfer{ + Amount: 500, + BlockHeight: 5, + KeyImage: ki, + } + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + if err := markTransferSpent(s, ki, 20); err != nil { + t.Fatal(err) + } + + got, err := getTransfer(s, ki) + if err != nil { + t.Fatal(err) + } + if !got.Spent { + t.Fatal("transfer should be marked spent") + } + if got.SpentHeight != 20 { + t.Fatalf("spent height = %d, want 20", got.SpentHeight) + } +} + +func TestTransferListUnspent(t *testing.T) { + s := newTestStore(t) + for i := byte(0); i < 3; i++ { + var ki types.KeyImage + ki[0] = i + 1 + tr := Transfer{ + Amount: uint64(i+1) * 100, + BlockHeight: uint64(i), + KeyImage: ki, + } + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + } + + // Mark one as spent + var ki types.KeyImage + ki[0] = 2 + if err := markTransferSpent(s, ki, 10); err != nil { + t.Fatal(err) + } + + transfers, err := listTransfers(s) + if err != nil { + t.Fatal(err) + } + if len(transfers) != 3 { + t.Fatalf("got %d transfers, want 3", len(transfers)) + } + + unspent := 0 + for _, tr := range transfers { + if !tr.Spent { + unspent++ + } + } + if unspent != 2 { + t.Fatalf("got %d unspent, want 2", unspent) + } +} + +func TestTransferSpendable(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, Spent: false} + // Chain height 20, no unlock, non-coinbase: should be spendable + if !tr.IsSpendable(20, false) { + t.Fatal("should be spendable") + } + + // Spent transfer + tr.Spent = true + if tr.IsSpendable(20, false) { + t.Fatal("spent should not be spendable") + } + + // Coinbase with insufficient maturity + tr.Spent = false + tr.Coinbase = true + if tr.IsSpendable(10, false) { + t.Fatal("immature coinbase should not be spendable") + } + // Coinbase with sufficient maturity (height 5 + 10 = 15 <= 20) + if !tr.IsSpendable(20, false) { + t.Fatal("mature coinbase should be spendable") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestTransfer -v ./wallet/` +Expected: FAIL + +**Step 3: Implement transfer type and storage** + +Create `wallet/transfer.go`: +```go +package wallet + +import ( + "encoding/json" + "fmt" + + store "forge.lthn.ai/core/go-store" + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/types" +) + +const groupTransfers = "transfers" + +// KeyPair holds an ephemeral key pair for a received output. +type KeyPair struct { + Public types.PublicKey `json:"public"` + Secret types.SecretKey `json:"secret"` +} + +// Transfer represents an owned output detected during scanning. +type Transfer struct { + TxHash types.Hash `json:"tx_hash"` + OutputIndex uint32 `json:"output_index"` + Amount uint64 `json:"amount"` + GlobalIndex uint64 `json:"global_index"` + BlockHeight uint64 `json:"block_height"` + EphemeralKey KeyPair `json:"ephemeral_key"` + KeyImage types.KeyImage `json:"key_image"` + Spent bool `json:"spent"` + SpentHeight uint64 `json:"spent_height"` + Coinbase bool `json:"coinbase"` + UnlockTime uint64 `json:"unlock_time"` +} + +// IsSpendable returns true if this transfer can be used as a tx input. +func (t *Transfer) IsSpendable(chainHeight uint64, _ bool) bool { + if t.Spent { + return false + } + if t.Coinbase { + if t.BlockHeight+config.MinedMoneyUnlockWindow > chainHeight { + return false + } + } + if t.UnlockTime > 0 && t.UnlockTime > chainHeight { + return false + } + return true +} + +func putTransfer(s *store.Store, tr *Transfer) error { + val, err := json.Marshal(tr) + if err != nil { + return fmt.Errorf("wallet: marshal transfer: %w", err) + } + return s.Set(groupTransfers, tr.KeyImage.String(), string(val)) +} + +func getTransfer(s *store.Store, ki types.KeyImage) (*Transfer, error) { + val, err := s.Get(groupTransfers, ki.String()) + if err != nil { + return nil, fmt.Errorf("wallet: get transfer %s: %w", ki, err) + } + var tr Transfer + if err := json.Unmarshal([]byte(val), &tr); err != nil { + return nil, fmt.Errorf("wallet: unmarshal transfer: %w", err) + } + return &tr, nil +} + +func markTransferSpent(s *store.Store, ki types.KeyImage, height uint64) error { + tr, err := getTransfer(s, ki) + if err != nil { + return err + } + tr.Spent = true + tr.SpentHeight = height + return putTransfer(s, tr) +} + +func listTransfers(s *store.Store) ([]Transfer, error) { + pairs, err := s.List(groupTransfers) + if err != nil { + return nil, fmt.Errorf("wallet: list transfers: %w", err) + } + transfers := make([]Transfer, 0, len(pairs)) + for _, kv := range pairs { + var tr Transfer + if err := json.Unmarshal([]byte(kv.Value), &tr); err != nil { + return nil, fmt.Errorf("wallet: unmarshal transfer: %w", err) + } + transfers = append(transfers, tr) + } + return transfers, nil +} +``` + +**Note:** This uses `s.List(group)` which returns all key-value pairs. Verify go-store supports this; if not, use `s.Keys(group)` + `s.Get()` per key. + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run TestTransfer -v ./wallet/` +Expected: PASS (all 4 tests) + +**Step 5: Commit** + +```bash +git add wallet/transfer.go wallet/transfer_test.go +git commit -m "feat(wallet): transfer type and go-store persistence" +``` + +--- + +## Task 5: Scanner Interface and V1Scanner + +**Files:** +- Create: `wallet/scanner.go` +- Create: `wallet/scanner_test.go` + +**Context:** The scanner detects outputs belonging to a wallet by performing ECDH derivation. For each transaction output, it derives the expected public key and compares it to the output's target key. + +**Crypto API:** +- `crypto.GenerateKeyDerivation(pub, sec [32]byte) ([32]byte, error)` — ECDH +- `crypto.DerivePublicKey(derivation [32]byte, index uint64, base [32]byte) ([32]byte, error)` +- `crypto.DeriveSecretKey(derivation [32]byte, index uint64, base [32]byte) ([32]byte, error)` +- `crypto.GenerateKeyImage(pub, sec [32]byte) ([32]byte, error)` + +**Step 1: Write failing tests** + +Create `wallet/scanner_test.go`: +```go +package wallet + +import ( + "testing" + + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +// makeTestTransaction creates a v0 tx with one output sent to the given address. +// Returns the tx, its hash, and the tx secret key used. +func makeTestTransaction(t *testing.T, destAddr *Account) (*types.Transaction, types.Hash, [32]byte) { + t.Helper() + + // Generate a one-time tx key pair. + txPub, txSec, err := crypto.GenerateKeys() + if err != nil { + t.Fatal(err) + } + + // Derive output key: derivation = txSec * destViewPub + derivation, err := crypto.GenerateKeyDerivation( + [32]byte(destAddr.ViewPublicKey), txSec) + if err != nil { + t.Fatal(err) + } + + // Ephemeral public key for output 0. + ephPub, err := crypto.DerivePublicKey( + derivation, 0, [32]byte(destAddr.SpendPublicKey)) + if err != nil { + t.Fatal(err) + } + + tx := &types.Transaction{ + Version: 0, + Vin: []types.TxInput{types.TxInputGenesis{Height: 0}}, + Vout: []types.TxOutput{ + types.TxOutputBare{ + Amount: 1000, + Target: types.TxOutToKey{Key: types.PublicKey(ephPub)}, + }, + }, + Extra: BuildTxExtra(types.PublicKey(txPub)), + Attachment: wire.EncodeVarint(0), + } + tx.Signatures = [][]types.Signature{{}} // one empty ring for genesis + + txHash := wire.TransactionHash(tx) + return tx, txHash, txSec +} + +func TestV1ScannerDetectsOwnedOutput(t *testing.T) { + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + tx, txHash, _ := makeTestTransaction(t, acc) + extra, err := ParseTxExtra(tx.Extra) + if err != nil { + t.Fatal(err) + } + + scanner := NewV1Scanner(acc) + transfers, err := scanner.ScanTransaction(tx, txHash, 1, extra) + if err != nil { + t.Fatal(err) + } + if len(transfers) != 1 { + t.Fatalf("got %d transfers, want 1", len(transfers)) + } + if transfers[0].Amount != 1000 { + t.Fatalf("amount = %d, want 1000", transfers[0].Amount) + } + if transfers[0].OutputIndex != 0 { + t.Fatalf("output index = %d, want 0", transfers[0].OutputIndex) + } + // Key image should be non-zero. + var zero types.KeyImage + if transfers[0].KeyImage == zero { + t.Fatal("key image should be non-zero") + } +} + +func TestV1ScannerRejectsNonOwned(t *testing.T) { + acc1, _ := GenerateAccount() + acc2, _ := GenerateAccount() + + // Send to acc1, scan with acc2 — should find nothing. + tx, txHash, _ := makeTestTransaction(t, acc1) + extra, _ := ParseTxExtra(tx.Extra) + + scanner := NewV1Scanner(acc2) + transfers, err := scanner.ScanTransaction(tx, txHash, 1, extra) + if err != nil { + t.Fatal(err) + } + if len(transfers) != 0 { + t.Fatalf("got %d transfers, want 0", len(transfers)) + } +} + +func TestV1ScannerNoTxPubKey(t *testing.T) { + acc, _ := GenerateAccount() + + // Transaction with empty extra (no tx pub key). + tx := &types.Transaction{ + Version: 0, + Vin: []types.TxInput{types.TxInputGenesis{Height: 0}}, + Vout: []types.TxOutput{types.TxOutputBare{Amount: 100}}, + Extra: wire.EncodeVarint(0), + Attachment: wire.EncodeVarint(0), + } + txHash := wire.TransactionHash(tx) + extra, _ := ParseTxExtra(tx.Extra) + + scanner := NewV1Scanner(acc) + transfers, err := scanner.ScanTransaction(tx, txHash, 1, extra) + if err != nil { + t.Fatal(err) + } + if len(transfers) != 0 { + t.Fatalf("expected 0 transfers for missing tx pub key, got %d", len(transfers)) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestV1Scanner -v ./wallet/` +Expected: FAIL + +**Step 3: Implement scanner** + +Create `wallet/scanner.go`: +```go +package wallet + +import ( + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" +) + +// Scanner detects outputs belonging to a wallet. +type Scanner interface { + ScanTransaction(tx *types.Transaction, txHash types.Hash, + blockHeight uint64, extra *TxExtra) ([]Transfer, error) +} + +// V1Scanner implements Scanner for v0/v1 transactions using ECDH. +type V1Scanner struct { + account *Account +} + +// NewV1Scanner creates a scanner for the given account. +func NewV1Scanner(acc *Account) *V1Scanner { + return &V1Scanner{account: acc} +} + +// ScanTransaction checks each output for ownership via ECDH derivation. +func (s *V1Scanner) ScanTransaction(tx *types.Transaction, txHash types.Hash, + blockHeight uint64, extra *TxExtra) ([]Transfer, error) { + + // Need a TX public key to compute derivation. + if extra.TxPublicKey.IsZero() { + return nil, nil + } + + derivation, err := crypto.GenerateKeyDerivation( + [32]byte(extra.TxPublicKey), + [32]byte(s.account.ViewSecretKey)) + if err != nil { + return nil, nil // skip tx if derivation fails + } + + isCoinbase := len(tx.Vin) > 0 && tx.Vin[0].InputType() == types.InputTypeGenesis + + var transfers []Transfer + for i, out := range tx.Vout { + bare, ok := out.(types.TxOutputBare) + if !ok { + continue + } + + expectedPub, err := crypto.DerivePublicKey( + derivation, uint64(i), [32]byte(s.account.SpendPublicKey)) + if err != nil { + continue + } + + if types.PublicKey(expectedPub) != bare.Target.Key { + continue + } + + // Output is ours. Derive ephemeral secret key. + ephSec, err := crypto.DeriveSecretKey( + derivation, uint64(i), [32]byte(s.account.SpendSecretKey)) + if err != nil { + continue + } + + // Generate key image. + ki, err := crypto.GenerateKeyImage(expectedPub, ephSec) + if err != nil { + continue + } + + transfers = append(transfers, Transfer{ + TxHash: txHash, + OutputIndex: uint32(i), + Amount: bare.Amount, + BlockHeight: blockHeight, + EphemeralKey: KeyPair{ + Public: types.PublicKey(expectedPub), + Secret: types.SecretKey(ephSec), + }, + KeyImage: types.KeyImage(ki), + Coinbase: isCoinbase, + UnlockTime: extra.UnlockTime, + }) + } + + return transfers, nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run TestV1Scanner -v ./wallet/` +Expected: PASS (all 3 tests) + +**Step 5: Commit** + +```bash +git add wallet/scanner.go wallet/scanner_test.go +git commit -m "feat(wallet): V1Scanner with ECDH output detection" +``` + +--- + +## Task 6: New RPC Endpoints + +**Files:** +- Create: `rpc/wallet.go` +- Create: `rpc/wallet_test.go` + +**Context:** The wallet needs two new RPC endpoints: `GetRandomOutputs` (for ring selection decoys) wrapping the daemon's `getrandom_outs` legacy endpoint, and `SendRawTransaction` wrapping `/sendrawtransaction`. + +**Step 1: Write failing tests** + +Create `rpc/wallet_test.go`: +```go +package rpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetRandomOutputs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/getrandom_outs1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + resp := struct { + Outs []RandomOutputEntry `json:"outs"` + Status string `json:"status"` + }{ + Outs: []RandomOutputEntry{ + {GlobalIndex: 10, PublicKey: "aa" + repeatHex("00", 31)}, + {GlobalIndex: 20, PublicKey: "bb" + repeatHex("00", 31)}, + }, + Status: "OK", + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + client := NewClient(srv.URL) + outs, err := client.GetRandomOutputs(1000, 2) + if err != nil { + t.Fatal(err) + } + if len(outs) != 2 { + t.Fatalf("got %d outputs, want 2", len(outs)) + } + if outs[0].GlobalIndex != 10 { + t.Fatalf("first output global index = %d, want 10", outs[0].GlobalIndex) + } +} + +func TestSendRawTransaction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/sendrawtransaction" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + resp := struct { + Status string `json:"status"` + }{Status: "OK"} + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + client := NewClient(srv.URL) + err := client.SendRawTransaction([]byte{0x01, 0x02}) + if err != nil { + t.Fatal(err) + } +} + +func TestSendRawTransactionRejected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + Status string `json:"status"` + }{Status: "Failed"} + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + client := NewClient(srv.URL) + err := client.SendRawTransaction([]byte{0x01}) + if err == nil { + t.Fatal("expected error for rejected tx") + } +} + +func repeatHex(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + out += s + } + return out +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run "TestGetRandom|TestSendRaw" -v ./rpc/` +Expected: FAIL + +**Step 3: Implement RPC endpoints** + +Create `rpc/wallet.go`: +```go +package rpc + +import ( + "encoding/hex" + "fmt" +) + +// RandomOutputEntry is a decoy output returned by getrandom_outs. +type RandomOutputEntry struct { + GlobalIndex uint64 `json:"global_index"` + PublicKey string `json:"public_key"` +} + +// GetRandomOutputs fetches random decoy outputs for ring construction. +// Uses the legacy /getrandom_outs1 endpoint. +func (c *Client) GetRandomOutputs(amount uint64, count int) ([]RandomOutputEntry, error) { + params := struct { + Amount uint64 `json:"amount"` + Count int `json:"outs_count"` + }{Amount: amount, Count: count} + + var resp struct { + Outs []RandomOutputEntry `json:"outs"` + Status string `json:"status"` + } + + if err := c.legacyCall("/getrandom_outs1", params, &resp); err != nil { + return nil, err + } + if resp.Status != "OK" { + return nil, fmt.Errorf("getrandom_outs: status %q", resp.Status) + } + return resp.Outs, nil +} + +// SendRawTransaction submits a serialised transaction for relay. +// Uses the legacy /sendrawtransaction endpoint. +func (c *Client) SendRawTransaction(txBlob []byte) error { + params := struct { + TxAsHex string `json:"tx_as_hex"` + }{TxAsHex: hex.EncodeToString(txBlob)} + + var resp struct { + Status string `json:"status"` + } + + if err := c.legacyCall("/sendrawtransaction", params, &resp); err != nil { + return err + } + if resp.Status != "OK" { + return fmt.Errorf("sendrawtransaction: status %q", resp.Status) + } + return nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run "TestGetRandom|TestSendRaw" -v ./rpc/` +Expected: PASS (all 3 tests) + +**Step 5: Commit** + +```bash +git add rpc/wallet.go rpc/wallet_test.go +git commit -m "feat(rpc): GetRandomOutputs and SendRawTransaction endpoints" +``` + +--- + +## Task 7: Signer Interface and NLSAGSigner + +**Files:** +- Create: `wallet/signer.go` +- Create: `wallet/signer_test.go` + +**Context:** Wraps the CGo ring signature functions behind a Signer interface. + +**Step 1: Write failing tests** + +Create `wallet/signer_test.go`: +```go +package wallet + +import ( + "testing" + + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" +) + +func TestNLSAGSignerRoundTrip(t *testing.T) { + // Generate ephemeral key pair (simulating an owned output). + pub, sec, err := crypto.GenerateKeys() + if err != nil { + t.Fatal(err) + } + + // Generate key image. + ki, err := crypto.GenerateKeyImage(pub, sec) + if err != nil { + t.Fatal(err) + } + + // Build a ring of 3 keys with our key at index 1. + ring := make([]types.PublicKey, 3) + for i := range ring { + p, _, err := crypto.GenerateKeys() + if err != nil { + t.Fatal(err) + } + ring[i] = types.PublicKey(p) + } + ring[1] = types.PublicKey(pub) + + // Sign. + var prefixHash types.Hash + prefixHash[0] = 0xFF + + signer := &NLSAGSigner{} + sigs, err := signer.SignInput(prefixHash, KeyPair{ + Public: types.PublicKey(pub), + Secret: types.SecretKey(sec), + }, ring, 1) + if err != nil { + t.Fatal(err) + } + if len(sigs) != 3 { + t.Fatalf("got %d sigs, want 3", len(sigs)) + } + + // Verify. + pubs := make([][32]byte, len(ring)) + for i, k := range ring { + pubs[i] = [32]byte(k) + } + rawSigs := make([][64]byte, len(sigs)) + for i, s := range sigs { + rawSigs[i] = [64]byte(s) + } + if !crypto.CheckRingSignature([32]byte(prefixHash), ki, pubs, rawSigs) { + t.Fatal("ring signature verification failed") + } +} + +func TestNLSAGSignerVersion(t *testing.T) { + signer := &NLSAGSigner{} + if signer.Version() != 1 { + t.Fatalf("version = %d, want 1", signer.Version()) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestNLSAG -v ./wallet/` +Expected: FAIL + +**Step 3: Implement signer** + +Create `wallet/signer.go`: +```go +package wallet + +import ( + "fmt" + + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" +) + +// Signer produces signatures for transaction inputs. +type Signer interface { + SignInput(prefixHash types.Hash, ephemeral KeyPair, + ring []types.PublicKey, realIndex int) ([]types.Signature, error) + Version() uint64 +} + +// NLSAGSigner signs using NLSAG ring signatures (v0/v1 transactions). +type NLSAGSigner struct{} + +// SignInput generates an NLSAG ring signature. +func (s *NLSAGSigner) SignInput(prefixHash types.Hash, ephemeral KeyPair, + ring []types.PublicKey, realIndex int) ([]types.Signature, error) { + + ki, err := crypto.GenerateKeyImage( + [32]byte(ephemeral.Public), [32]byte(ephemeral.Secret)) + if err != nil { + return nil, fmt.Errorf("wallet: key image: %w", err) + } + + pubs := make([][32]byte, len(ring)) + for i, k := range ring { + pubs[i] = [32]byte(k) + } + + rawSigs, err := crypto.GenerateRingSignature( + [32]byte(prefixHash), ki, pubs, + [32]byte(ephemeral.Secret), realIndex) + if err != nil { + return nil, fmt.Errorf("wallet: ring signature: %w", err) + } + + sigs := make([]types.Signature, len(rawSigs)) + for i, rs := range rawSigs { + sigs[i] = types.Signature(rs) + } + return sigs, nil +} + +// Version returns the transaction version this signer supports. +func (s *NLSAGSigner) Version() uint64 { return 1 } +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run TestNLSAG -v ./wallet/` +Expected: PASS (both tests) + +**Step 5: Commit** + +```bash +git add wallet/signer.go wallet/signer_test.go +git commit -m "feat(wallet): NLSAGSigner with ring signature interface" +``` + +--- + +## Task 8: RingSelector Interface and RPCRingSelector + +**Files:** +- Create: `wallet/ring.go` +- Create: `wallet/ring_test.go` + +**Context:** RPCRingSelector fetches decoy outputs from the daemon and builds rings. + +**Step 1: Write failing tests** + +Create `wallet/ring_test.go`: +```go +package wallet + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-blockchain/rpc" + "forge.lthn.ai/core/go-blockchain/types" +) + +func TestRPCRingSelector(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + Outs []rpc.RandomOutputEntry `json:"outs"` + Status string `json:"status"` + }{ + Status: "OK", + } + // Return 12 decoys (more than ring size, selector picks subset). + for i := 0; i < 12; i++ { + var key types.PublicKey + key[0] = byte(i + 1) + resp.Outs = append(resp.Outs, rpc.RandomOutputEntry{ + GlobalIndex: uint64((i + 1) * 100), + PublicKey: key.String(), + }) + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + client := rpc.NewClient(srv.URL) + selector := NewRPCRingSelector(client) + + members, err := selector.SelectRing(1000, 500, 10) + if err != nil { + t.Fatal(err) + } + if len(members) != 10 { + t.Fatalf("got %d ring members, want 10", len(members)) + } + + // Verify no duplicates. + seen := make(map[uint64]bool) + for _, m := range members { + if seen[m.GlobalIndex] { + t.Fatalf("duplicate global index %d", m.GlobalIndex) + } + seen[m.GlobalIndex] = true + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestRPCRing -v ./wallet/` +Expected: FAIL + +**Step 3: Implement ring selector** + +Create `wallet/ring.go`: +```go +package wallet + +import ( + "fmt" + + "forge.lthn.ai/core/go-blockchain/rpc" + "forge.lthn.ai/core/go-blockchain/types" +) + +// RingMember is a public key + global index used in ring construction. +type RingMember struct { + PublicKey types.PublicKey + GlobalIndex uint64 +} + +// RingSelector picks decoy outputs for ring signatures. +type RingSelector interface { + SelectRing(amount uint64, realGlobalIndex uint64, + ringSize int) ([]RingMember, error) +} + +// RPCRingSelector fetches decoys from the daemon via RPC. +type RPCRingSelector struct { + client *rpc.Client +} + +// NewRPCRingSelector creates a ring selector using the given RPC client. +func NewRPCRingSelector(client *rpc.Client) *RPCRingSelector { + return &RPCRingSelector{client: client} +} + +// SelectRing fetches random outputs and returns ringSize decoy members, +// excluding the real output's global index. +func (s *RPCRingSelector) SelectRing(amount uint64, realGlobalIndex uint64, + ringSize int) ([]RingMember, error) { + + // Request extra outputs to account for filtering. + outs, err := s.client.GetRandomOutputs(amount, ringSize+5) + if err != nil { + return nil, fmt.Errorf("wallet: get random outputs: %w", err) + } + + var members []RingMember + seen := map[uint64]bool{realGlobalIndex: true} + for _, out := range outs { + if seen[out.GlobalIndex] { + continue + } + seen[out.GlobalIndex] = true + + pk, err := types.PublicKeyFromHex(out.PublicKey) + if err != nil { + continue + } + members = append(members, RingMember{ + PublicKey: pk, + GlobalIndex: out.GlobalIndex, + }) + if len(members) >= ringSize { + break + } + } + + if len(members) < ringSize { + return nil, fmt.Errorf("wallet: insufficient decoys: got %d, need %d", + len(members), ringSize) + } + + return members, nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run TestRPCRing -v ./wallet/` +Expected: PASS + +**Step 5: Commit** + +```bash +git add wallet/ring.go wallet/ring_test.go +git commit -m "feat(wallet): RPCRingSelector for decoy output selection" +``` + +--- + +## Task 9: Builder Interface and V1Builder + +**Files:** +- Create: `wallet/builder.go` +- Create: `wallet/builder_test.go` + +**Context:** Constructs signed v1 transactions. Takes source transfers, destinations, and fee. Handles change output, ring construction, and NLSAG signing. + +**Step 1: Write failing tests** + +Create `wallet/builder_test.go`: +```go +package wallet + +import ( + "testing" + + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" +) + +// mockRingSelector returns fixed ring members for testing. +type mockRingSelector struct{} + +func (m *mockRingSelector) SelectRing(amount uint64, realGlobalIndex uint64, + ringSize int) ([]RingMember, error) { + members := make([]RingMember, ringSize) + for i := range members { + pub, _, _ := crypto.GenerateKeys() + members[i] = RingMember{ + PublicKey: types.PublicKey(pub), + GlobalIndex: uint64(i * 100), + } + } + return members, nil +} + +func TestV1BuilderBasic(t *testing.T) { + sender, _ := GenerateAccount() + receiver, _ := GenerateAccount() + + // Create a fake source transfer. + pub, sec, _ := crypto.GenerateKeys() + ki, _ := crypto.GenerateKeyImage(pub, sec) + source := Transfer{ + Amount: 5000, + GlobalIndex: 42, + EphemeralKey: KeyPair{ + Public: types.PublicKey(pub), + Secret: types.SecretKey(sec), + }, + KeyImage: types.KeyImage(ki), + } + + builder := NewV1Builder(&NLSAGSigner{}, &mockRingSelector{}) + req := &BuildRequest{ + Sources: []Transfer{source}, + Destinations: []Destination{ + {Address: receiver.Address(), Amount: 3000}, + }, + Fee: 1000, + SenderAddress: sender.Address(), + } + + tx, err := builder.Build(req) + if err != nil { + t.Fatal(err) + } + if tx.Version != 1 { + t.Fatalf("version = %d, want 1", tx.Version) + } + if len(tx.Vin) != 1 { + t.Fatalf("inputs = %d, want 1", len(tx.Vin)) + } + // 1 destination + 1 change = 2 outputs + if len(tx.Vout) != 2 { + t.Fatalf("outputs = %d, want 2", len(tx.Vout)) + } + + // Verify output amounts sum correctly. + var outSum uint64 + for _, out := range tx.Vout { + bare := out.(types.TxOutputBare) + outSum += bare.Amount + } + if outSum+1000 != 5000 { + t.Fatalf("output sum + fee = %d, want 5000", outSum+1000) + } +} + +func TestV1BuilderInsufficientFunds(t *testing.T) { + sender, _ := GenerateAccount() + receiver, _ := GenerateAccount() + + pub, sec, _ := crypto.GenerateKeys() + ki, _ := crypto.GenerateKeyImage(pub, sec) + source := Transfer{ + Amount: 1000, + GlobalIndex: 1, + EphemeralKey: KeyPair{Public: types.PublicKey(pub), Secret: types.SecretKey(sec)}, + KeyImage: types.KeyImage(ki), + } + + builder := NewV1Builder(&NLSAGSigner{}, &mockRingSelector{}) + req := &BuildRequest{ + Sources: []Transfer{source}, + Destinations: []Destination{{Address: receiver.Address(), Amount: 2000}}, + Fee: 1000, + SenderAddress: sender.Address(), + } + + _, err := builder.Build(req) + if err == nil { + t.Fatal("expected insufficient funds error") + } +} + +func TestV1BuilderExactAmount(t *testing.T) { + sender, _ := GenerateAccount() + receiver, _ := GenerateAccount() + + pub, sec, _ := crypto.GenerateKeys() + ki, _ := crypto.GenerateKeyImage(pub, sec) + source := Transfer{ + Amount: 2000, + GlobalIndex: 5, + EphemeralKey: KeyPair{Public: types.PublicKey(pub), Secret: types.SecretKey(sec)}, + KeyImage: types.KeyImage(ki), + } + + builder := NewV1Builder(&NLSAGSigner{}, &mockRingSelector{}) + req := &BuildRequest{ + Sources: []Transfer{source}, + Destinations: []Destination{{Address: receiver.Address(), Amount: 1000}}, + Fee: 1000, + SenderAddress: sender.Address(), + } + + tx, err := builder.Build(req) + if err != nil { + t.Fatal(err) + } + // Exact amount: no change output needed. + if len(tx.Vout) != 1 { + t.Fatalf("outputs = %d, want 1 (no change)", len(tx.Vout)) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run TestV1Builder -v ./wallet/` +Expected: FAIL + +**Step 3: Implement builder** + +Create `wallet/builder.go`: +```go +package wallet + +import ( + "bytes" + "fmt" + "sort" + + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +// Destination is a recipient address and amount. +type Destination struct { + Address types.Address + Amount uint64 +} + +// BuildRequest holds the parameters for building a transaction. +type BuildRequest struct { + Sources []Transfer + Destinations []Destination + Fee uint64 + SenderAddress types.Address +} + +// Builder constructs unsigned or signed transactions. +type Builder interface { + Build(req *BuildRequest) (*types.Transaction, error) +} + +// V1Builder constructs v1 transactions with NLSAG ring signatures. +type V1Builder struct { + signer Signer + ringSelector RingSelector +} + +// NewV1Builder creates a builder with the given signer and ring selector. +func NewV1Builder(signer Signer, ringSelector RingSelector) *V1Builder { + return &V1Builder{signer: signer, ringSelector: ringSelector} +} + +// Build constructs a signed v1 transaction. +func (b *V1Builder) Build(req *BuildRequest) (*types.Transaction, error) { + // Validate amounts. + var sourceSum uint64 + for _, s := range req.Sources { + sourceSum += s.Amount + } + var destSum uint64 + for _, d := range req.Destinations { + destSum += d.Amount + } + if sourceSum < destSum+req.Fee { + return nil, fmt.Errorf("wallet: insufficient funds: have %d, need %d", + sourceSum, destSum+req.Fee) + } + + // Generate one-time TX key pair. + txPub, txSec, err := crypto.GenerateKeys() + if err != nil { + return nil, fmt.Errorf("wallet: generate tx keys: %w", err) + } + + tx := &types.Transaction{Version: 1} + + // Build inputs. + type inputMeta struct { + ring []types.PublicKey + realIndex int + ephemeral KeyPair + } + var metas []inputMeta + + for _, src := range req.Sources { + ringMembers, err := b.ringSelector.SelectRing( + src.Amount, src.GlobalIndex, int(config.DefaultDecoySetSize)) + if err != nil { + return nil, fmt.Errorf("wallet: select ring: %w", err) + } + + // Insert real output into the ring. + all := append(ringMembers, RingMember{ + PublicKey: src.EphemeralKey.Public, + GlobalIndex: src.GlobalIndex, + }) + + // Sort by global index (consensus rule). + sort.Slice(all, func(i, j int) bool { + return all[i].GlobalIndex < all[j].GlobalIndex + }) + + // Find real index after sorting. + realIdx := -1 + ring := make([]types.PublicKey, len(all)) + offsets := make([]types.TxOutRef, len(all)) + for i, m := range all { + ring[i] = m.PublicKey + offsets[i] = types.TxOutRef{ + Tag: types.RefTypeGlobalIndex, + GlobalIndex: m.GlobalIndex, + } + if m.GlobalIndex == src.GlobalIndex { + realIdx = i + } + } + if realIdx < 0 { + return nil, fmt.Errorf("wallet: real output not found in ring") + } + + tx.Vin = append(tx.Vin, types.TxInputToKey{ + Amount: src.Amount, + KeyOffsets: offsets, + KeyImage: src.KeyImage, + EtcDetails: wire.EncodeVarint(0), + }) + metas = append(metas, inputMeta{ + ring: ring, + realIndex: realIdx, + ephemeral: src.EphemeralKey, + }) + } + + // Build outputs. + outputIdx := 0 + for _, dest := range req.Destinations { + outKey, err := deriveOutputKey(txSec, dest.Address, uint64(outputIdx)) + if err != nil { + return nil, err + } + tx.Vout = append(tx.Vout, types.TxOutputBare{ + Amount: dest.Amount, + Target: types.TxOutToKey{Key: outKey}, + }) + outputIdx++ + } + + // Change output. + change := sourceSum - destSum - req.Fee + if change > 0 { + outKey, err := deriveOutputKey(txSec, req.SenderAddress, uint64(outputIdx)) + if err != nil { + return nil, err + } + tx.Vout = append(tx.Vout, types.TxOutputBare{ + Amount: change, + Target: types.TxOutToKey{Key: outKey}, + }) + } + + // Build extra. + tx.Extra = BuildTxExtra(types.PublicKey(txPub)) + tx.Attachment = wire.EncodeVarint(0) + + // Compute prefix hash. + prefixHash := wire.TransactionPrefixHash(tx) + + // Sign each input. + tx.Signatures = make([][]types.Signature, len(tx.Vin)) + for i, meta := range metas { + sigs, err := b.signer.SignInput(prefixHash, meta.ephemeral, meta.ring, meta.realIndex) + if err != nil { + return nil, fmt.Errorf("wallet: sign input %d: %w", i, err) + } + tx.Signatures[i] = sigs + } + + return tx, nil +} + +// deriveOutputKey derives the one-time output key for a destination. +func deriveOutputKey(txSec [32]byte, addr types.Address, outputIdx uint64) (types.PublicKey, error) { + derivation, err := crypto.GenerateKeyDerivation( + [32]byte(addr.ViewPublicKey), txSec) + if err != nil { + return types.PublicKey{}, fmt.Errorf("wallet: output derivation: %w", err) + } + ephPub, err := crypto.DerivePublicKey( + derivation, outputIdx, [32]byte(addr.SpendPublicKey)) + if err != nil { + return types.PublicKey{}, fmt.Errorf("wallet: derive output key: %w", err) + } + return types.PublicKey(ephPub), nil +} + +// SerializeTransaction serialises a transaction to wire format bytes. +func SerializeTransaction(tx *types.Transaction) ([]byte, error) { + var buf bytes.Buffer + enc := wire.NewEncoder(&buf) + wire.EncodeTransaction(enc, tx) + if err := enc.Err(); err != nil { + return nil, fmt.Errorf("wallet: encode tx: %w", err) + } + return buf.Bytes(), nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run TestV1Builder -v ./wallet/` +Expected: PASS (all 3 tests) + +**Step 5: Commit** + +```bash +git add wallet/builder.go wallet/builder_test.go +git commit -m "feat(wallet): V1Builder for transaction construction with ring signatures" +``` + +--- + +## Task 10: Wallet Orchestrator + +**Files:** +- Create: `wallet/wallet.go` +- Create: `wallet/wallet_test.go` + +**Context:** The Wallet struct ties everything together: sync from chain, compute balance, construct and send transactions. + +**Step 1: Write failing tests** + +Create `wallet/wallet_test.go`: +```go +package wallet + +import ( + "testing" + + store "forge.lthn.ai/core/go-store" + "forge.lthn.ai/core/go-blockchain/chain" + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +func makeTestBlock(t *testing.T, height uint64, prevHash types.Hash, + destAccount *Account) (*types.Block, types.Hash) { + t.Helper() + + txPub, txSec, _ := crypto.GenerateKeys() + derivation, _ := crypto.GenerateKeyDerivation( + [32]byte(destAccount.ViewPublicKey), txSec) + ephPub, _ := crypto.DerivePublicKey( + derivation, 0, [32]byte(destAccount.SpendPublicKey)) + + minerTx := types.Transaction{ + Version: 0, + Vin: []types.TxInput{types.TxInputGenesis{Height: height}}, + Vout: []types.TxOutput{ + types.TxOutputBare{ + Amount: 1_000_000_000_000, // 1 LTHN + Target: types.TxOutToKey{Key: types.PublicKey(ephPub)}, + }, + }, + Extra: BuildTxExtra(types.PublicKey(txPub)), + Attachment: wire.EncodeVarint(0), + } + minerTx.Signatures = [][]types.Signature{{}} + + blk := &types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Timestamp: 1770897600 + height*120, + PrevID: prevHash, + }, + MinerTx: minerTx, + } + + hash := wire.BlockHash(blk) + return blk, hash +} + +func TestWalletSyncAndBalance(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + acc, _ := GenerateAccount() + c := chain.New(s) + + // Store 3 blocks, each paying 1 LTHN to our account. + var prevHash types.Hash + for h := uint64(0); h < 3; h++ { + blk, hash := makeTestBlock(t, h, prevHash, acc) + meta := &chain.BlockMeta{Hash: hash, Height: h} + if err := c.PutBlock(blk, meta); err != nil { + t.Fatal(err) + } + // Index the output. + txHash := wire.TransactionHash(&blk.MinerTx) + c.PutOutput(1_000_000_000_000, txHash, 0) + prevHash = hash + } + + w := NewWallet(acc, s, c, nil) + if err := w.Sync(); err != nil { + t.Fatal(err) + } + + confirmed, locked, err := w.Balance() + if err != nil { + t.Fatal(err) + } + + // All 3 blocks are coinbase with MinedMoneyUnlockWindow=10. + // Chain height = 3, so all 3 are locked (height + 10 > 3). + if locked != 3_000_000_000_000 { + t.Fatalf("locked = %d, want 3_000_000_000_000", locked) + } + if confirmed != 0 { + t.Fatalf("confirmed = %d, want 0 (all locked)", confirmed) + } +} + +func TestWalletTransfers(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + acc, _ := GenerateAccount() + c := chain.New(s) + + blk, hash := makeTestBlock(t, 0, types.Hash{}, acc) + meta := &chain.BlockMeta{Hash: hash, Height: 0} + c.PutBlock(blk, meta) + txHash := wire.TransactionHash(&blk.MinerTx) + c.PutOutput(1_000_000_000_000, txHash, 0) + + w := NewWallet(acc, s, c, nil) + w.Sync() + + transfers, err := w.Transfers() + if err != nil { + t.Fatal(err) + } + if len(transfers) != 1 { + t.Fatalf("got %d transfers, want 1", len(transfers)) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test -race -run "TestWallet" -v ./wallet/` +Expected: FAIL + +**Step 3: Implement wallet orchestrator** + +Create `wallet/wallet.go`: +```go +package wallet + +import ( + "fmt" + "sort" + "strconv" + + store "forge.lthn.ai/core/go-store" + "forge.lthn.ai/core/go-blockchain/chain" + "forge.lthn.ai/core/go-blockchain/rpc" + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +const ( + scanHeightKey = "scan_height" +) + +// Wallet ties together scanning, building, and sending. +type Wallet struct { + account *Account + store *store.Store + chain *chain.Chain + client *rpc.Client + scanner Scanner + signer Signer + ringSelector RingSelector + builder Builder +} + +// NewWallet creates a wallet with v1 defaults. +func NewWallet(account *Account, s *store.Store, c *chain.Chain, + client *rpc.Client) *Wallet { + + scanner := NewV1Scanner(account) + signer := &NLSAGSigner{} + var ringSelector RingSelector + var builder Builder + if client != nil { + ringSelector = NewRPCRingSelector(client) + builder = NewV1Builder(signer, ringSelector) + } + + return &Wallet{ + account: account, + store: s, + chain: c, + client: client, + scanner: scanner, + signer: signer, + ringSelector: ringSelector, + builder: builder, + } +} + +// Sync scans blocks from the last checkpoint to the chain tip. +func (w *Wallet) Sync() error { + lastScanned := w.loadScanHeight() + + chainHeight, err := w.chain.Height() + if err != nil { + return fmt.Errorf("wallet: chain height: %w", err) + } + + for h := lastScanned; h < chainHeight; h++ { + blk, _, err := w.chain.GetBlockByHeight(h) + if err != nil { + return fmt.Errorf("wallet: get block %d: %w", h, err) + } + + // Scan miner tx. + if err := w.scanTx(&blk.MinerTx, h); err != nil { + return err + } + + // Scan regular transactions. + for _, txHash := range blk.TxHashes { + tx, _, err := w.chain.GetTransaction(txHash) + if err != nil { + continue // skip missing txs + } + if err := w.scanTx(tx, h); err != nil { + return err + } + } + + w.saveScanHeight(h + 1) + } + + return nil +} + +func (w *Wallet) scanTx(tx *types.Transaction, blockHeight uint64) error { + txHash := wire.TransactionHash(tx) + extra, err := ParseTxExtra(tx.Extra) + if err != nil { + return nil // skip unparseable extras + } + + // Detect owned outputs. + transfers, err := w.scanner.ScanTransaction(tx, txHash, blockHeight, extra) + if err != nil { + return nil + } + for i := range transfers { + if err := putTransfer(w.store, &transfers[i]); err != nil { + return fmt.Errorf("wallet: store transfer: %w", err) + } + } + + // Check key images for spend detection. + for _, vin := range tx.Vin { + toKey, ok := vin.(types.TxInputToKey) + if !ok { + continue + } + // Try to mark any matching transfer as spent. + tr, err := getTransfer(w.store, toKey.KeyImage) + if err != nil { + continue // not our transfer + } + if !tr.Spent { + markTransferSpent(w.store, toKey.KeyImage, blockHeight) + } + } + + return nil +} + +// Balance returns confirmed (spendable) and locked amounts. +func (w *Wallet) Balance() (confirmed, locked uint64, err error) { + chainHeight, err := w.chain.Height() + if err != nil { + return 0, 0, err + } + + transfers, err := listTransfers(w.store) + if err != nil { + return 0, 0, err + } + + for _, tr := range transfers { + if tr.Spent { + continue + } + if tr.IsSpendable(chainHeight, false) { + confirmed += tr.Amount + } else { + locked += tr.Amount + } + } + + return confirmed, locked, nil +} + +// Send constructs and submits a transaction. +func (w *Wallet) Send(destinations []Destination, fee uint64) (*types.Transaction, error) { + if w.builder == nil || w.client == nil { + return nil, fmt.Errorf("wallet: no RPC client configured") + } + + chainHeight, err := w.chain.Height() + if err != nil { + return nil, err + } + + var destSum uint64 + for _, d := range destinations { + destSum += d.Amount + } + needed := destSum + fee + + // Coin selection: largest-first greedy. + transfers, err := listTransfers(w.store) + if err != nil { + return nil, err + } + + // Filter spendable and sort by amount descending. + var spendable []Transfer + for _, tr := range transfers { + if tr.IsSpendable(chainHeight, false) { + spendable = append(spendable, tr) + } + } + sort.Slice(spendable, func(i, j int) bool { + return spendable[i].Amount > spendable[j].Amount + }) + + var selected []Transfer + var selectedSum uint64 + for _, tr := range spendable { + selected = append(selected, tr) + selectedSum += tr.Amount + if selectedSum >= needed { + break + } + } + if selectedSum < needed { + return nil, fmt.Errorf("wallet: insufficient balance: have %d, need %d", + selectedSum, needed) + } + + req := &BuildRequest{ + Sources: selected, + Destinations: destinations, + Fee: fee, + SenderAddress: w.account.Address(), + } + + tx, err := w.builder.Build(req) + if err != nil { + return nil, err + } + + blob, err := SerializeTransaction(tx) + if err != nil { + return nil, err + } + + if err := w.client.SendRawTransaction(blob); err != nil { + return nil, fmt.Errorf("wallet: submit tx: %w", err) + } + + // Optimistically mark sources as spent. + for _, src := range selected { + markTransferSpent(w.store, src.KeyImage, chainHeight) + } + + return tx, nil +} + +// Transfers returns all tracked transfers. +func (w *Wallet) Transfers() ([]Transfer, error) { + return listTransfers(w.store) +} + +func (w *Wallet) loadScanHeight() uint64 { + val, err := w.store.Get(walletGroup, scanHeightKey) + if err != nil { + return 0 + } + h, _ := strconv.ParseUint(val, 10, 64) + return h +} + +func (w *Wallet) saveScanHeight(h uint64) { + w.store.Set(walletGroup, scanHeightKey, strconv.FormatUint(h, 10)) +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -race -run "TestWallet" -v ./wallet/` +Expected: PASS (both tests) + +**Step 5: Commit** + +```bash +git add wallet/wallet.go wallet/wallet_test.go +git commit -m "feat(wallet): orchestrator with sync, balance, and send" +``` + +--- + +## Task 11: Integration Test and Documentation + +**Files:** +- Create: `wallet/integration_test.go` +- Modify: `docs/architecture.md` +- Modify: `docs/history.md` + +**Step 1: Write integration test** + +Create `wallet/integration_test.go`: +```go +//go:build integration + +package wallet + +import ( + "testing" + + store "forge.lthn.ai/core/go-store" + "forge.lthn.ai/core/go-blockchain/chain" + "forge.lthn.ai/core/go-blockchain/rpc" +) + +func TestWalletIntegration(t *testing.T) { + client := rpc.NewClient("http://localhost:46941") + + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + c := chain.New(s) + + // Sync chain first. + if err := c.Sync(client); err != nil { + t.Fatalf("chain sync: %v", err) + } + + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + w := NewWallet(acc, s, c, client) + if err := w.Sync(); err != nil { + t.Fatal(err) + } + + confirmed, locked, err := w.Balance() + if err != nil { + t.Fatal(err) + } + t.Logf("Balance: confirmed=%d, locked=%d", confirmed, locked) + + transfers, err := w.Transfers() + if err != nil { + t.Fatal(err) + } + t.Logf("Transfers: %d", len(transfers)) +} +``` + +**Step 2: Update docs/architecture.md** + +Add a `### wallet` section describing the package, its interfaces, and its role. + +**Step 3: Update docs/history.md** + +Add Phase 6 completion entry with commit hashes and summary. + +**Step 4: Run full test suite** + +Run: `go test -race ./...` +Expected: PASS + +Run: `go vet ./...` +Expected: clean + +**Step 5: Commit** + +```bash +git add wallet/integration_test.go docs/architecture.md docs/history.md +git commit -m "docs: Phase 6 wallet core documentation and integration test" +``` + +--- + +## Verification Checklist + +1. `go test -race ./...` — all tests pass +2. `go vet ./...` — no warnings +3. Coverage: `go test -coverprofile=cover.out ./wallet/ && go tool cover -func=cover.out` — target >80% +4. Mnemonic round-trip works for random keys +5. Scanner detects owned outputs and rejects non-owned +6. Builder produces valid signed transactions +7. Wallet sync tracks transfers and computes balance correctly + +## Notes for Implementer + +- **go-store `List()` method**: Verify this exists. If not, use `Keys()` + individual `Get()` calls, or iterate with `Count()` + sequential numeric keys. +- **`wire.DecodeVarintBytes`**: If this doesn't exist, add it — the TxExtra parser needs to decode varints from byte slices without an `io.Reader`. +- **`types.PublicKey.IsZero()`**: Verify this method exists. If not, compare against `types.PublicKey{}`. +- **`types.PublicKeyFromHex()`**: Verify this exists in types package. If not, add a simple hex-decode + copy into `[32]byte`. +- **C++ mnemonic wordlist**: Extract from `~/Code/LetheanNetwork/blockchain/src/common/mnemonic-encoding.cpp` using the wordsArray (lines 1665-3292). The map (lines 36-1663) has the same words in different order — use the **array** for index-based lookup.