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:
parent
f66ef2e61d
commit
359952075a
2 changed files with 422 additions and 0 deletions
98
wallet/scanner.go
Normal file
98
wallet/scanner.go
Normal 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
324
wallet/scanner_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue