feat(wallet): V1Scanner with ECDH output detection

Scanner interface with V1Scanner implementation for v0/v1 transactions.
Derives ephemeral keys via ECDH, generates key images, and tracks
coinbase status and unlock times.

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Claude 2026-02-20 23:17:22 +00:00
parent f66ef2e61d
commit 359952075a
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 422 additions and 0 deletions

98
wallet/scanner.go Normal file
View file

@ -0,0 +1,98 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// You may obtain a copy of the licence at:
//
// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl
//
// SPDX-License-Identifier: EUPL-1.2
package wallet
import (
"forge.lthn.ai/core/go-blockchain/crypto"
"forge.lthn.ai/core/go-blockchain/types"
)
// Scanner detects outputs belonging to a wallet within a transaction.
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 derivation.
// For each output it performs: derivation = viewSecret * txPubKey, then checks
// whether DerivePublicKey(derivation, i, spendPub) matches the output key.
type V1Scanner struct {
account *Account
}
// NewV1Scanner returns a scanner bound to the given account.
func NewV1Scanner(acc *Account) *V1Scanner {
return &V1Scanner{account: acc}
}
// ScanTransaction examines every output in tx and returns a Transfer for each
// output that belongs to the scanner's account. The caller must supply a
// pre-parsed TxExtra so that the tx public key is available.
func (s *V1Scanner) ScanTransaction(tx *types.Transaction, txHash types.Hash,
blockHeight uint64, extra *TxExtra) ([]Transfer, error) {
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
}
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
}
ephSec, err := crypto.DeriveSecretKey(
derivation, uint64(i), [32]byte(s.account.SpendSecretKey))
if err != nil {
continue
}
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
}

324
wallet/scanner_test.go Normal file
View file

@ -0,0 +1,324 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
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 coinbase tx with one output sent to destAddr.
// It returns the transaction, its hash, and the ephemeral secret key used to
// construct the output (for test verification).
func makeTestTransaction(t *testing.T, destAddr *Account) (*types.Transaction, types.Hash, [32]byte) {
t.Helper()
txPub, txSec, err := crypto.GenerateKeys()
if err != nil {
t.Fatal(err)
}
derivation, err := crypto.GenerateKeyDerivation(
[32]byte(destAddr.ViewPublicKey), txSec)
if err != nil {
t.Fatal(err)
}
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),
Signatures: [][]types.Signature{{}},
}
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)
}
if transfers[0].TxHash != txHash {
t.Fatal("tx hash mismatch")
}
if transfers[0].BlockHeight != 1 {
t.Fatalf("block height = %d, want 1", transfers[0].BlockHeight)
}
var zeroKI types.KeyImage
if transfers[0].KeyImage == zeroKI {
t.Fatal("key image should be non-zero")
}
var zeroPK types.PublicKey
if transfers[0].EphemeralKey.Public == zeroPK {
t.Fatal("ephemeral public key should be non-zero")
}
var zeroSK types.SecretKey
if transfers[0].EphemeralKey.Secret == zeroSK {
t.Fatal("ephemeral secret key should be non-zero")
}
}
func TestV1ScannerRejectsNonOwned(t *testing.T) {
acc1, err := GenerateAccount()
if err != nil {
t.Fatal(err)
}
acc2, err := GenerateAccount()
if err != nil {
t.Fatal(err)
}
tx, txHash, _ := makeTestTransaction(t, acc1)
extra, err := ParseTxExtra(tx.Extra)
if err != nil {
t.Fatal(err)
}
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, err := GenerateAccount()
if err != nil {
t.Fatal(err)
}
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),
Signatures: [][]types.Signature{{}},
}
txHash := wire.TransactionHash(tx)
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) != 0 {
t.Fatalf("expected 0 transfers for missing tx pub key, got %d", len(transfers))
}
}
func TestV1ScannerCoinbaseFlag(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].Coinbase {
t.Fatal("should be marked as coinbase (TxInputGenesis)")
}
}
func TestV1ScannerUnlockTime(t *testing.T) {
acc, err := GenerateAccount()
if err != nil {
t.Fatal(err)
}
txPub, txSec, err := crypto.GenerateKeys()
if err != nil {
t.Fatal(err)
}
derivation, err := crypto.GenerateKeyDerivation(
[32]byte(acc.ViewPublicKey), txSec)
if err != nil {
t.Fatal(err)
}
ephPub, err := crypto.DerivePublicKey(
derivation, 0, [32]byte(acc.SpendPublicKey))
if err != nil {
t.Fatal(err)
}
// Build extra with unlock time.
extraRaw := buildTestExtra(types.PublicKey(txPub), 500, 0)
tx := &types.Transaction{
Version: 0,
Vin: []types.TxInput{types.TxInputGenesis{Height: 0}},
Vout: []types.TxOutput{
types.TxOutputBare{
Amount: 2000,
Target: types.TxOutToKey{Key: types.PublicKey(ephPub)},
},
},
Extra: extraRaw,
Attachment: wire.EncodeVarint(0),
Signatures: [][]types.Signature{{}},
}
txHash := wire.TransactionHash(tx)
extra, err := ParseTxExtra(tx.Extra)
if err != nil {
t.Fatal(err)
}
scanner := NewV1Scanner(acc)
transfers, err := scanner.ScanTransaction(tx, txHash, 10, extra)
if err != nil {
t.Fatal(err)
}
if len(transfers) != 1 {
t.Fatalf("got %d transfers, want 1", len(transfers))
}
if transfers[0].UnlockTime != 500 {
t.Fatalf("unlock time = %d, want 500", transfers[0].UnlockTime)
}
}
func TestV1ScannerMultipleOutputs(t *testing.T) {
acc, err := GenerateAccount()
if err != nil {
t.Fatal(err)
}
txPub, txSec, err := crypto.GenerateKeys()
if err != nil {
t.Fatal(err)
}
derivation, err := crypto.GenerateKeyDerivation(
[32]byte(acc.ViewPublicKey), txSec)
if err != nil {
t.Fatal(err)
}
// Create two outputs for our account at indices 0 and 1.
eph0, err := crypto.DerivePublicKey(derivation, 0, [32]byte(acc.SpendPublicKey))
if err != nil {
t.Fatal(err)
}
eph1, err := crypto.DerivePublicKey(derivation, 1, [32]byte(acc.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: 100,
Target: types.TxOutToKey{Key: types.PublicKey(eph0)},
},
types.TxOutputBare{
Amount: 200,
Target: types.TxOutToKey{Key: types.PublicKey(eph1)},
},
},
Extra: BuildTxExtra(types.PublicKey(txPub)),
Attachment: wire.EncodeVarint(0),
Signatures: [][]types.Signature{{}},
}
txHash := wire.TransactionHash(tx)
extra, err := ParseTxExtra(tx.Extra)
if err != nil {
t.Fatal(err)
}
scanner := NewV1Scanner(acc)
transfers, err := scanner.ScanTransaction(tx, txHash, 5, extra)
if err != nil {
t.Fatal(err)
}
if len(transfers) != 2 {
t.Fatalf("got %d transfers, want 2", len(transfers))
}
if transfers[0].OutputIndex != 0 || transfers[0].Amount != 100 {
t.Fatalf("transfer[0]: index=%d amount=%d, want index=0 amount=100",
transfers[0].OutputIndex, transfers[0].Amount)
}
if transfers[1].OutputIndex != 1 || transfers[1].Amount != 200 {
t.Fatalf("transfer[1]: index=%d amount=%d, want index=1 amount=200",
transfers[1].OutputIndex, transfers[1].Amount)
}
// Key images must be unique per output.
if transfers[0].KeyImage == transfers[1].KeyImage {
t.Fatal("key images should differ between outputs")
}
}
func TestV1ScannerImplementsInterface(t *testing.T) {
acc, err := GenerateAccount()
if err != nil {
t.Fatal(err)
}
var _ Scanner = NewV1Scanner(acc)
}