feat(rpc): add GetRandomOutputs and SendRawTransaction endpoints

GetRandomOutputs wraps /getrandom_outs1 for ring selection decoys.
SendRawTransaction wraps /sendrawtransaction for tx submission.

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

59
rpc/wallet.go Normal file
View file

@ -0,0 +1,59 @@
// 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 rpc
import (
"encoding/hex"
"fmt"
)
// RandomOutputEntry is a decoy output returned by getrandom_outs.
type RandomOutputEntry struct {
GlobalIndex uint64 `json:"global_index"`
PublicKey string `json:"public_key"`
}
// GetRandomOutputs fetches random decoy outputs for ring construction.
// Uses the legacy /getrandom_outs1 endpoint (not available via /json_rpc).
func (c *Client) GetRandomOutputs(amount uint64, count int) ([]RandomOutputEntry, error) {
params := struct {
Amount uint64 `json:"amount"`
Count int `json:"outs_count"`
}{Amount: amount, Count: count}
var resp struct {
Outs []RandomOutputEntry `json:"outs"`
Status string `json:"status"`
}
if err := c.legacyCall("/getrandom_outs1", params, &resp); err != nil {
return nil, err
}
if resp.Status != "OK" {
return nil, fmt.Errorf("getrandom_outs: status %q", resp.Status)
}
return resp.Outs, nil
}
// SendRawTransaction submits a serialised transaction for relay.
// Uses the legacy /sendrawtransaction endpoint (not available via /json_rpc).
func (c *Client) SendRawTransaction(txBlob []byte) error {
params := struct {
TxAsHex string `json:"tx_as_hex"`
}{TxAsHex: hex.EncodeToString(txBlob)}
var resp struct {
Status string `json:"status"`
}
if err := c.legacyCall("/sendrawtransaction", params, &resp); err != nil {
return err
}
if resp.Status != "OK" {
return fmt.Errorf("sendrawtransaction: status %q", resp.Status)
}
return nil
}

104
rpc/wallet_test.go Normal file
View file

@ -0,0 +1,104 @@
// 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 rpc
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetRandomOutputs_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/getrandom_outs1" {
t.Errorf("path: got %s, want /getrandom_outs1", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"outs": [
{"global_index": 10, "public_key": "aa00000000000000000000000000000000000000000000000000000000000000"},
{"global_index": 20, "public_key": "bb00000000000000000000000000000000000000000000000000000000000000"}
],
"status": "OK"
}`))
}))
defer srv.Close()
c := NewClient(srv.URL)
outs, err := c.GetRandomOutputs(1000, 2)
if err != nil {
t.Fatalf("GetRandomOutputs: %v", err)
}
if len(outs) != 2 {
t.Fatalf("outs: got %d, want 2", len(outs))
}
if outs[0].GlobalIndex != 10 {
t.Errorf("outs[0].GlobalIndex: got %d, want 10", outs[0].GlobalIndex)
}
if outs[0].PublicKey != "aa00000000000000000000000000000000000000000000000000000000000000" {
t.Errorf("outs[0].PublicKey: got %q", outs[0].PublicKey)
}
if outs[1].GlobalIndex != 20 {
t.Errorf("outs[1].GlobalIndex: got %d, want 20", outs[1].GlobalIndex)
}
}
func TestGetRandomOutputs_Bad_Status(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct{ Status string }{Status: "BUSY"})
}))
defer srv.Close()
c := NewClient(srv.URL)
_, err := c.GetRandomOutputs(1000, 2)
if err == nil {
t.Fatal("expected error for non-OK status")
}
}
func TestSendRawTransaction_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sendrawtransaction" {
t.Errorf("path: got %s, want /sendrawtransaction", r.URL.Path)
}
var req struct {
TxAsHex string `json:"tx_as_hex"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.TxAsHex != "0102" {
t.Errorf("tx_as_hex: got %q, want %q", req.TxAsHex, "0102")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"OK"}`))
}))
defer srv.Close()
c := NewClient(srv.URL)
err := c.SendRawTransaction([]byte{0x01, 0x02})
if err != nil {
t.Fatalf("SendRawTransaction: %v", err)
}
}
func TestSendRawTransaction_Bad_Rejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct{ Status string }{Status: "Failed"})
}))
defer srv.Close()
c := NewClient(srv.URL)
err := c.SendRawTransaction([]byte{0x01})
if err == nil {
t.Fatal("expected error for rejected transaction")
}
}