docs: Phase 6 wallet core design
Interface-driven wallet with Scanner, Signer, Builder, RingSelector abstractions. v1 NLSAG ships first, v2+ Zarcanum architecture ready. Full send+receive: key management, output scanning, tx construction, ring selection via RPC, encrypted key storage in go-store. Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
28e3da63cb
commit
4a07e6ca70
1 changed files with 320 additions and 0 deletions
320
docs/plans/2026-02-20-wallet-core-design.md
Normal file
320
docs/plans/2026-02-20-wallet-core-design.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# Phase 6 Design: Wallet Core
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 6 adds a `wallet/` package that provides full send+receive wallet
|
||||
functionality for the Lethean blockchain. It uses interface-driven design
|
||||
with four core abstractions (Scanner, Signer, Builder, RingSelector) so
|
||||
v1 (NLSAG) implementations ship now and v2+ (Zarcanum/CLSAG) slot in later
|
||||
without changing callers.
|
||||
|
||||
The wallet scans the local chain (from Phase 5) for owned outputs using
|
||||
ECDH derivation, tracks transfers and balances, constructs v1 transactions
|
||||
with NLSAG ring signatures, selects decoy outputs via daemon RPC, and
|
||||
submits signed transactions for relay.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Scope | Full send+receive wallet |
|
||||
| Architecture | Interface-heavy (Scanner, Signer, Builder, RingSelector) |
|
||||
| Extra parsing | Wallet-critical only (tx pub key, unlock_time, derivation_hint) |
|
||||
| Key storage | go-store with Argon2id + AES-256-GCM encryption |
|
||||
| Ring selection | RPC random outputs from daemon |
|
||||
| TX versions | v1 (NLSAG) first, v2+ architecture ready |
|
||||
| HF progression | Chain will advance through hard forks soon |
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
The wallet defines four interfaces that decouple scanning, signing,
|
||||
building, and ring selection.
|
||||
|
||||
```go
|
||||
// Scanner detects outputs belonging to a wallet.
|
||||
type Scanner interface {
|
||||
ScanTransaction(tx *types.Transaction, txHash types.Hash,
|
||||
blockHeight uint64, extra *TxExtra) ([]Transfer, error)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// RingSelector picks decoy outputs for ring signatures.
|
||||
type RingSelector interface {
|
||||
SelectRing(amount uint64, realGlobalIndex uint64,
|
||||
ringSize int) ([]RingMember, error)
|
||||
}
|
||||
|
||||
// Builder constructs unsigned or signed transactions.
|
||||
type Builder interface {
|
||||
Build(req *BuildRequest) (*types.Transaction, error)
|
||||
}
|
||||
```
|
||||
|
||||
v1 implementations: `NLSAGSigner`, `RPCRingSelector`, `V1Scanner`, `V1Builder`.
|
||||
Future v2+: `CLSAGSigner`, `ZarcanumScanner`, `ZarcanumBuilder` -- same interfaces.
|
||||
|
||||
## Account & Key Management
|
||||
|
||||
```go
|
||||
type Account struct {
|
||||
SpendPublicKey types.PublicKey
|
||||
SpendSecretKey types.SecretKey
|
||||
ViewPublicKey types.PublicKey
|
||||
ViewSecretKey types.SecretKey
|
||||
CreatedAt uint64
|
||||
Flags uint8
|
||||
}
|
||||
```
|
||||
|
||||
### Creation methods
|
||||
|
||||
- `Generate()` -- fresh random keys.
|
||||
- `RestoreFromSeed(phrase, password)` -- 25-word mnemonic.
|
||||
- `RestoreViewOnly(viewSecret, spendPublic)` -- watch-only wallet.
|
||||
|
||||
### Key derivation
|
||||
|
||||
The spend secret key is the master secret. The view secret key is derived
|
||||
deterministically as `Keccak256(spend_secret_key)`. This matches the C++
|
||||
`account_base::generate()` pattern.
|
||||
|
||||
### Seed phrases
|
||||
|
||||
25 words from the CryptoNote wordlist. The 32-byte spend secret key is
|
||||
encoded as a mnemonic with a checksum word.
|
||||
|
||||
### Persistence
|
||||
|
||||
The passphrase goes through Argon2id (time=3, mem=64MB, threads=4) to
|
||||
produce a 32-byte key. The serialised account is encrypted with AES-256-GCM.
|
||||
The ciphertext + salt + nonce are stored in go-store group `wallet` with
|
||||
key `account`.
|
||||
|
||||
## Extra Field Parsing
|
||||
|
||||
A minimal parser for the three wallet-critical variant tags. Everything
|
||||
else stays as raw bytes.
|
||||
|
||||
```go
|
||||
type TxExtra struct {
|
||||
TxPublicKey types.PublicKey // tag 22
|
||||
UnlockTime uint64 // tag 14 (0 if absent)
|
||||
DerivationHint uint16 // tag 11 (0 if absent)
|
||||
Raw []byte // original bytes preserved
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Type | Size | Purpose |
|
||||
|-----|------|------|---------|
|
||||
| 22 | `crypto::public_key` | 32 bytes fixed | TX public key for ECDH |
|
||||
| 14 | `etc_tx_details_unlock_time` | varint | Block/timestamp lock |
|
||||
| 11 | `tx_derivation_hint` | 2 bytes fixed | Fast scanning hint |
|
||||
|
||||
Unknown tags are skipped using the same boundary detection the wire package
|
||||
uses. Raw bytes are preserved so `TransactionHash()` still works.
|
||||
|
||||
## Output Scanning & Transfer Tracking
|
||||
|
||||
### Transfer type
|
||||
|
||||
```go
|
||||
type Transfer struct {
|
||||
TxHash types.Hash
|
||||
OutputIndex uint32
|
||||
Amount uint64
|
||||
GlobalIndex uint64
|
||||
BlockHeight uint64
|
||||
EphemeralKey KeyPair
|
||||
KeyImage types.KeyImage
|
||||
Spent bool
|
||||
SpentHeight uint64
|
||||
Flags uint8
|
||||
UnlockTime uint64
|
||||
}
|
||||
|
||||
type KeyPair struct {
|
||||
Public types.PublicKey
|
||||
Secret types.SecretKey
|
||||
}
|
||||
```
|
||||
|
||||
### V1Scanner flow
|
||||
|
||||
1. Parse `TxExtra` -- extract TX public key (tag 22).
|
||||
2. ECDH: `derivation = GenerateKeyDerivation(txPubKey, viewSecretKey)`.
|
||||
3. For each output `i`:
|
||||
- Derive: `expectedPub = DerivePublicKey(derivation, i, spendPublicKey)`.
|
||||
- If `expectedPub == output.Target.Key` -- output is ours.
|
||||
- Derive secret: `ephemeralSec = DeriveSecretKey(derivation, i, spendSecretKey)`.
|
||||
- Key image: `ki = GenerateKeyImage(expectedPub, ephemeralSec)`.
|
||||
4. Return matching `Transfer` records.
|
||||
|
||||
### Balance calculation
|
||||
|
||||
A transfer is **spendable** when:
|
||||
- Not spent (`!Spent`)
|
||||
- Not locked (coinbase maturity: `blockHeight + MinedMoneyUnlockWindow <= chainHeight`)
|
||||
- Unlock time satisfied (if non-zero)
|
||||
|
||||
### Spend detection
|
||||
|
||||
During sync, when a `TxInputToKey` key image matches a stored transfer's
|
||||
key image, mark that transfer as spent.
|
||||
|
||||
## Transaction Construction
|
||||
|
||||
### V1Builder flow
|
||||
|
||||
1. **Validate** -- sum of source amounts >= sum of destinations + fee.
|
||||
2. **Generate tx key pair** -- random one-time key for this transaction.
|
||||
3. **Build inputs** -- for each source:
|
||||
- `RingSelector.SelectRing()` for decoys from daemon RPC.
|
||||
- Sort ring members by global index (consensus rule).
|
||||
- Record real output position after sorting.
|
||||
- Create `TxInputToKey` with key offsets and key image.
|
||||
4. **Build outputs** -- for each destination:
|
||||
- Derive ephemeral key via ECDH with destination address.
|
||||
- Create `TxOutputBare` with amount and `TxOutToKey`.
|
||||
5. **Build extra** -- `BuildTxExtra(txPubKey)`.
|
||||
6. **Compute prefix hash** -- `wire.TransactionPrefixHash(&tx)`.
|
||||
7. **Sign** -- `Signer.SignInput()` per input with ring and ephemeral keys.
|
||||
8. **Return** complete signed transaction.
|
||||
|
||||
### Ring selection
|
||||
|
||||
`RPCRingSelector` calls a new RPC endpoint `GetRandomOutputs(amount, count)`
|
||||
that wraps the daemon's `getrandom_outs` method.
|
||||
|
||||
### Change handling
|
||||
|
||||
If source amount exceeds destinations + fee, the builder adds a change
|
||||
output back to the sender's address. Extra amount is never silently
|
||||
absorbed as fee.
|
||||
|
||||
### Submission
|
||||
|
||||
`rpc.Client.SendRawTransaction(txBlob)` -- new RPC endpoint wrapping
|
||||
the daemon's `/sendrawtransaction` legacy path.
|
||||
|
||||
## Wallet Orchestrator
|
||||
|
||||
```go
|
||||
type Wallet struct {
|
||||
account *Account
|
||||
store *store.Store
|
||||
chain *chain.Chain
|
||||
scanner Scanner
|
||||
signer Signer
|
||||
ringSelector RingSelector
|
||||
builder Builder
|
||||
}
|
||||
|
||||
func NewWallet(account *Account, s *store.Store, c *chain.Chain,
|
||||
client *rpc.Client) *Wallet
|
||||
|
||||
func (w *Wallet) Sync() error
|
||||
func (w *Wallet) Balance() (confirmed, locked uint64, err error)
|
||||
func (w *Wallet) Send(destinations []Destination, fee uint64) (*types.Transaction, error)
|
||||
func (w *Wallet) Transfers() ([]Transfer, error)
|
||||
```
|
||||
|
||||
### Sync flow
|
||||
|
||||
1. Read last scanned height from go-store (group `wallet`, key `scan_height`).
|
||||
2. Get chain height from `chain.Height()`.
|
||||
3. For each block from `lastScanned+1` to `chainHeight-1`:
|
||||
- Fetch block, scan miner tx + regular txs.
|
||||
- Store new transfers, check key images for spend detection.
|
||||
4. Update `scan_height`.
|
||||
|
||||
### Transfer storage
|
||||
|
||||
go-store group `transfers`, key = `keyImage.String()`, value = JSON
|
||||
`Transfer`. Spend detection is O(1) key image lookup.
|
||||
|
||||
### Send flow
|
||||
|
||||
1. Verify sufficient balance.
|
||||
2. Coin selection: largest-first, greedy.
|
||||
3. Build + sign via `builder.Build()`.
|
||||
4. Submit via `rpc.Client.SendRawTransaction()`.
|
||||
5. Mark source transfers as spent (optimistic, confirmed on next sync).
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
wallet/
|
||||
wallet.go -- Wallet struct, NewWallet, Sync, Send, Balance
|
||||
account.go -- Account, Generate, RestoreFromSeed, Save/Load
|
||||
scanner.go -- Scanner interface + V1Scanner
|
||||
signer.go -- Signer interface + NLSAGSigner
|
||||
builder.go -- Builder interface + V1Builder
|
||||
ring.go -- RingSelector interface + RPCRingSelector
|
||||
transfer.go -- Transfer type, transfer storage helpers
|
||||
extra.go -- TxExtra parsing and construction
|
||||
mnemonic.go -- 25-word seed phrase encode/decode
|
||||
wallet_test.go -- Wallet sync + send integration tests
|
||||
account_test.go -- Key generation + seed round-trip tests
|
||||
scanner_test.go -- Output detection tests
|
||||
builder_test.go -- Transaction construction tests
|
||||
extra_test.go -- Extra parsing tests
|
||||
mnemonic_test.go -- Mnemonic encode/decode tests
|
||||
```
|
||||
|
||||
### New RPC endpoints (in `rpc/`)
|
||||
|
||||
- `GetRandomOutputs(amount, count)` -- for ring selection.
|
||||
- `SendRawTransaction(txBlob)` -- for transaction submission.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit tests (go-store `:memory:`)
|
||||
|
||||
- Account: generate, seed round-trip, save/load with encryption, watch-only.
|
||||
- Extra: parse known extras, extract tx pub key, skip unknown tags, build extra.
|
||||
- Scanner: detect owned output, reject non-owned, handle missing tx pub key.
|
||||
- Builder: valid tx construction, insufficient funds error, change output.
|
||||
- Signer: NLSAG ring signature round-trip (sign + verify).
|
||||
- Mnemonic: encode/decode round-trip, invalid word rejection, checksum.
|
||||
|
||||
### Mock RPC tests
|
||||
|
||||
- RPCRingSelector with httptest server returning canned random outputs.
|
||||
- SendRawTransaction with mock acceptance/rejection.
|
||||
|
||||
### Integration test (build-tagged)
|
||||
|
||||
- Generate wallet, sync from testnet, verify balance matches expected.
|
||||
- Construct and submit a transaction (if testnet has spendable outputs).
|
||||
|
||||
### Coverage target
|
||||
|
||||
Greater than 80% across `wallet/` files.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go-store` -- persistence (already in go.mod)
|
||||
- `forge.lthn.ai/core/go-blockchain/crypto` -- key operations, signatures
|
||||
- `forge.lthn.ai/core/go-blockchain/chain` -- block/tx retrieval
|
||||
- `forge.lthn.ai/core/go-blockchain/rpc` -- daemon communication
|
||||
- `forge.lthn.ai/core/go-blockchain/wire` -- serialisation, hashing
|
||||
- `forge.lthn.ai/core/go-blockchain/types` -- core types
|
||||
- `forge.lthn.ai/core/go-blockchain/config` -- constants
|
||||
- `golang.org/x/crypto` -- Argon2id (already a dependency)
|
||||
|
||||
No new external dependencies.
|
||||
|
||||
## C++ Reference Files
|
||||
|
||||
- `src/currency_core/account.h` -- account key structure, seed phrases
|
||||
- `src/currency_core/currency_format_utils.h` -- `is_out_to_acc()`, output detection
|
||||
- `src/currency_core/currency_format_utils.cpp` -- `construct_tx()`, key derivation
|
||||
- `src/currency_core/currency_basic.h` -- extra variant tags, type definitions
|
||||
- `src/wallet/wallet2.h` -- full wallet lifecycle, transfer tracking
|
||||
- `src/wallet/wallet2_base.h` -- transfer details, wallet state containers
|
||||
Loading…
Add table
Reference in a new issue