From 7e31e706c583e64d476ae7bc0a9b8d1d1e27cad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 23:20:15 +0000 Subject: [PATCH] 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 --- wallet/ring.go | 75 ++++++++++++++++++++++++++ wallet/ring_test.go | 126 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 wallet/ring.go create mode 100644 wallet/ring_test.go diff --git a/wallet/ring.go b/wallet/ring.go new file mode 100644 index 0000000..11493b1 --- /dev/null +++ b/wallet/ring.go @@ -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 +} diff --git a/wallet/ring_test.go b/wallet/ring_test.go new file mode 100644 index 0000000..12878d3 --- /dev/null +++ b/wallet/ring_test.go @@ -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") + } +}