feat(wallet): RPCRingSelector for decoy output selection
RingSelector interface with RPCRingSelector that fetches random outputs from the daemon, excludes the real output and duplicates, and returns the requested ring size. Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
359952075a
commit
7e31e706c5
2 changed files with 201 additions and 0 deletions
75
wallet/ring.go
Normal file
75
wallet/ring.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// 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 (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go-blockchain/rpc"
|
||||
"forge.lthn.ai/core/go-blockchain/types"
|
||||
)
|
||||
|
||||
// RingMember is a public key and 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 returns a RingSelector backed by the given RPC client.
|
||||
func NewRPCRingSelector(client *rpc.Client) *RPCRingSelector {
|
||||
return &RPCRingSelector{client: client}
|
||||
}
|
||||
|
||||
// SelectRing fetches random outputs from the daemon and returns ringSize
|
||||
// decoy members, excluding the real output and any duplicates.
|
||||
func (s *RPCRingSelector) SelectRing(amount uint64, realGlobalIndex uint64, ringSize int) ([]RingMember, error) {
|
||||
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
|
||||
}
|
||||
126
wallet/ring_test.go
Normal file
126
wallet/ring_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// 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 (
|
||||
"encoding/hex"
|
||||
"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) {
|
||||
type entry struct {
|
||||
GlobalIndex uint64 `json:"global_index"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
resp := struct {
|
||||
Outs []entry `json:"outs"`
|
||||
Status string `json:"status"`
|
||||
}{Status: "OK"}
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
var key types.PublicKey
|
||||
key[0] = byte(i + 1)
|
||||
resp.Outs = append(resp.Outs, entry{
|
||||
GlobalIndex: uint64((i + 1) * 100),
|
||||
PublicKey: hex.EncodeToString(key[:]),
|
||||
})
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if m.GlobalIndex == 500 {
|
||||
t.Fatal("real global index should be excluded from decoys")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCRingSelectorExcludesReal(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type entry struct {
|
||||
GlobalIndex uint64 `json:"global_index"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
resp := struct {
|
||||
Outs []entry `json:"outs"`
|
||||
Status string `json:"status"`
|
||||
}{Status: "OK"}
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
var key types.PublicKey
|
||||
key[0] = byte(i + 1)
|
||||
gidx := uint64((i + 1) * 100)
|
||||
if i == 3 {
|
||||
gidx = 42 // this is the real output
|
||||
}
|
||||
resp.Outs = append(resp.Outs, entry{
|
||||
GlobalIndex: gidx,
|
||||
PublicKey: hex.EncodeToString(key[:]),
|
||||
})
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := rpc.NewClient(srv.URL)
|
||||
selector := NewRPCRingSelector(client)
|
||||
|
||||
members, err := selector.SelectRing(1000, 42, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range members {
|
||||
if m.GlobalIndex == 42 {
|
||||
t.Fatal("real output should be excluded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCRingSelectorInsufficientDecoys(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := struct {
|
||||
Outs []struct{} `json:"outs"`
|
||||
Status string `json:"status"`
|
||||
}{Status: "OK"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := rpc.NewClient(srv.URL)
|
||||
selector := NewRPCRingSelector(client)
|
||||
|
||||
_, err := selector.SelectRing(1000, 0, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient decoys error")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue