Skip to content

Commit

Permalink
Merge pull request #235 from SiaFoundation/nate/spent-utxos
Browse files Browse the repository at this point in the history
Add spent UTXO endpoints
  • Loading branch information
n8maninger authored Feb 21, 2025
2 parents 42659f7 + 5c5c1b4 commit ef1886e
Show file tree
Hide file tree
Showing 11 changed files with 497 additions and 24 deletions.
17 changes: 17 additions & 0 deletions .changeset/add_spent_element_endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
default: minor
---

# Added Spent Element Endpoints

Added two new endpoints `[GET] /outputs/siacoin/:id/spent` and `[GET] /outputs/siafund/:id/spent`. These endpoints will return a boolean, indicating whether the UTXO was spent, and the transaction it was spent in. These endpoints are designed to make verifying Atomic swaps easier.

#### Example Usage

````
$ curl http://localhost:9980/api/outputs/siacoin/9b89152bb967130326702c9bfb51109e9f80274ec314ba58d9ef49b881340f2f/spent
{
spent: true,
event: {}
}
```
7 changes: 7 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,10 @@ type SiafundElementsResponse struct {
Basis types.ChainIndex `json:"basis"`
Outputs []types.SiafundElement `json:"outputs"`
}

// ElementSpentResponse is the response type for /outputs/siacoin/:id/spent and
// /outputs/siafund/:id/spent.
type ElementSpentResponse struct {
Spent bool `json:"spent"`
Event *wallet.Event `json:"event,omitempty"`
}
203 changes: 203 additions & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,209 @@ func TestConstructV2Siacoins(t *testing.T) {
}
}

func TestSpentElement(t *testing.T) {
log := zaptest.NewLogger(t)

n, genesisBlock := testutil.V2Network()
senderPrivateKey := types.GeneratePrivateKey()
senderPolicy := types.SpendPolicy{Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(senderPrivateKey.PublicKey()))}
senderAddr := senderPolicy.Address()

receiverPrivateKey := types.GeneratePrivateKey()
receiverPolicy := types.SpendPolicy{Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(receiverPrivateKey.PublicKey()))}
receiverAddr := receiverPolicy.Address()

genesisBlock.Transactions[0].SiacoinOutputs[0] = types.SiacoinOutput{
Value: types.Siacoins(100),
Address: senderAddr,
}
genesisBlock.Transactions[0].SiafundOutputs[0].Address = senderAddr

// create wallets
dbstore, tipState, err := chain.NewDBStore(chain.NewMemDB(), n, genesisBlock)
if err != nil {
t.Fatal(err)
}
cm := chain.NewManager(dbstore, tipState)

ws, err := sqlite.OpenDatabase(filepath.Join(t.TempDir(), "wallets.db"), log.Named("sqlite3"))
if err != nil {
t.Fatal(err)
}
defer ws.Close()

peerStore, err := sqlite.NewPeerStore(ws)
if err != nil {
t.Fatal(err)
}

syncerListener, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
}
defer syncerListener.Close()

// create the syncer
s := syncer.New(syncerListener, cm, peerStore, gateway.Header{
GenesisID: genesisBlock.ID(),
UniqueID: gateway.GenerateUniqueID(),
NetAddress: syncerListener.Addr().String(),
})

wm, err := wallet.NewManager(cm, ws, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull))
if err != nil {
t.Fatal(err)
}
defer wm.Close()

c := runServer(t, cm, s, wm)

// trigger initial scan
testutil.MineBlocks(t, cm, types.VoidAddress, 1)
waitForBlock(t, cm, ws)

sce, basis, err := c.AddressSiacoinOutputs(senderAddr, 0, 100)
if err != nil {
t.Fatal(err)
} else if len(sce) != 1 {
t.Fatalf("expected 1 siacoin element, got %v", len(sce))
}

// check if the element is spent
spent, err := c.SpentSiacoinElement(sce[0].ID)
if err != nil {
t.Fatal(err)
} else if spent.Spent {
t.Fatal("expected siacoin element to be unspent")
} else if spent.Event != nil {
t.Fatalf("expected siacoin element to have no event, got %v", spent.Event)
}

// spend the element
txn := types.V2Transaction{
SiacoinInputs: []types.V2SiacoinInput{
{
Parent: sce[0],
SatisfiedPolicy: types.SatisfiedPolicy{
Policy: senderPolicy,
},
},
},
SiacoinOutputs: []types.SiacoinOutput{
{
Value: sce[0].SiacoinOutput.Value,
Address: receiverAddr,
},
},
}
cs, err := c.ConsensusTipState()
if err != nil {
t.Fatal(err)
}
txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{
senderPrivateKey.SignHash(cs.InputSigHash(txn)),
}

if err := c.TxpoolBroadcast(basis, nil, []types.V2Transaction{txn}); err != nil {
t.Fatal(err)
}
testutil.MineBlocks(t, cm, types.VoidAddress, 1)
waitForBlock(t, cm, ws)

// check if the element is spent
spent, err = c.SpentSiacoinElement(sce[0].ID)
if err != nil {
t.Fatal(err)
} else if !spent.Spent {
t.Fatal("expected siacoin element to be spent")
} else if types.TransactionID(spent.Event.ID) != txn.ID() {
t.Fatalf("expected siacoin element to have event %q, got %q", txn.ID(), spent.Event.ID)
} else if spent.Event.Type != wallet.EventTypeV2Transaction {
t.Fatalf("expected siacoin element to have type %q, got %q", wallet.EventTypeV2Transaction, spent.Event.Type)
}

// mine until the utxo is pruned
testutil.MineBlocks(t, cm, types.VoidAddress, 144)
waitForBlock(t, cm, ws)

_, err = c.SpentSiacoinElement(sce[0].ID)
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to contain %q, got %q", "not found", err)
}

sfe, basis, err := c.AddressSiafundOutputs(senderAddr, 0, 100)
if err != nil {
t.Fatal(err)
} else if len(sfe) != 1 {
t.Fatalf("expected 1 siafund element, got %v", len(sfe))
}

// check if the siafund element is spent
// check if the element is spent
spent, err = c.SpentSiafundElement(sfe[0].ID)
if err != nil {
t.Fatal(err)
} else if spent.Spent {
t.Fatal("expected siafund element to be unspent")
} else if spent.Event != nil {
t.Fatalf("expected siafund element to have no event, got %v", spent.Event)
}

// spend the element
txn = types.V2Transaction{
SiafundInputs: []types.V2SiafundInput{
{
Parent: sfe[0],
SatisfiedPolicy: types.SatisfiedPolicy{
Policy: senderPolicy,
},
ClaimAddress: senderAddr,
},
},
SiafundOutputs: []types.SiafundOutput{
{
Address: receiverAddr,
Value: sfe[0].SiafundOutput.Value,
},
},
}
cs, err = c.ConsensusTipState()
if err != nil {
t.Fatal(err)
}
txn.SiafundInputs[0].SatisfiedPolicy.Signatures = []types.Signature{
senderPrivateKey.SignHash(cs.InputSigHash(txn)),
}

if err := c.TxpoolBroadcast(basis, nil, []types.V2Transaction{txn}); err != nil {
t.Fatal(err)
}

testutil.MineBlocks(t, cm, types.VoidAddress, 1)
waitForBlock(t, cm, ws)

// check if the element is spent
spent, err = c.SpentSiafundElement(sfe[0].ID)
if err != nil {
t.Fatal(err)
} else if !spent.Spent {
t.Fatal("expected siafund element to be spent")
} else if types.TransactionID(spent.Event.ID) != txn.ID() {
t.Fatalf("expected siafund element to have event %q, got %q", txn.ID(), spent.Event.ID)
} else if spent.Event.Type != wallet.EventTypeV2Transaction {
t.Fatalf("expected siafund element to have type %q, got %q", wallet.EventTypeV2Transaction, spent.Event.Type)
}

// mine until the utxo is pruned
testutil.MineBlocks(t, cm, types.VoidAddress, 144)
waitForBlock(t, cm, ws)

_, err = c.SpentSiafundElement(sfe[0].ID)
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to contain %q, got %q", "not found", err)
}
}

func TestConstructV2Siafunds(t *testing.T) {
log := zaptest.NewLogger(t)

Expand Down
14 changes: 14 additions & 0 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ func (c *Client) Event(id types.Hash256) (resp wallet.Event, err error) {
return
}

// SpentSiacoinElement returns whether a siacoin output has been spent and the
// event that spent it.
func (c *Client) SpentSiacoinElement(id types.SiacoinOutputID) (resp ElementSpentResponse, err error) {
err = c.c.GET(fmt.Sprintf("/outputs/siacoin/%v/spent", id), &resp)
return
}

// SpentSiafundElement returns whether a siafund output has been spent and the
// event that spent it.
func (c *Client) SpentSiafundElement(id types.SiafundOutputID) (resp ElementSpentResponse, err error) {
err = c.c.GET(fmt.Sprintf("/outputs/siafund/%v/spent", id), &resp)
return
}

// A WalletClient provides methods for interacting with a particular wallet on a
// walletd API server.
type WalletClient struct {
Expand Down
64 changes: 62 additions & 2 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ type (

SiacoinElement(types.SiacoinOutputID) (types.SiacoinElement, error)
SiafundElement(types.SiafundOutputID) (types.SiafundElement, error)
// SiacoinElementSpentEvent returns the event of a spent siacoin element.
// If the element is not spent, the return value will be (Event{}, false, nil).
// If the element is not found, the error will be ErrNotFound. An element
// is only tracked for 144 blocks after it is spent.
SiacoinElementSpentEvent(types.SiacoinOutputID) (wallet.Event, bool, error)
// SiafundElementSpentEvent returns the event of a spent siafund element.
// If the element is not spent, the second return value will be (Event{}, false, nil).
// If the element is not found, the error will be ErrNotFound. An element
// is only tracked for 144 blocks after it is spent.
SiafundElementSpentEvent(types.SiafundOutputID) (wallet.Event, bool, error)

Reserve([]types.Hash256) error
Release([]types.Hash256)
Expand Down Expand Up @@ -580,6 +590,54 @@ func (s *server) walletsOutputsSiafundHandler(jc jape.Context) {
})
}

func (s *server) outputsSiacoinSpentHandlerGET(jc jape.Context) {
var id types.SiacoinOutputID
if jc.DecodeParam("id", &id) != nil {
return
}

event, spent, err := s.wm.SiacoinElementSpentEvent(id)
if errors.Is(err, wallet.ErrNotFound) {
jc.Error(err, http.StatusNotFound)
return
} else if jc.Check("couldn't load siacoin element", err) != nil {
return
}

resp := ElementSpentResponse{
Spent: spent,
}
if spent {
resp.Event = &event
}

jc.Encode(resp)
}

func (s *server) outputsSiafundSpentHandlerGET(jc jape.Context) {
var id types.SiafundOutputID
if jc.DecodeParam("id", &id) != nil {
return
}

event, spent, err := s.wm.SiafundElementSpentEvent(id)
if errors.Is(err, wallet.ErrNotFound) {
jc.Error(err, http.StatusNotFound)
return
} else if jc.Check("couldn't load siafund element", err) != nil {
return
}

resp := ElementSpentResponse{
Spent: spent,
}
if spent {
resp.Event = &event
}

jc.Encode(resp)
}

func (s *server) walletsReserveHandler(jc jape.Context) {
var wrr WalletReserveRequest
if jc.Decode(&wrr) != nil {
Expand Down Expand Up @@ -1324,8 +1382,10 @@ func NewServer(cm ChainManager, s Syncer, wm WalletManager, opts ...ServerOption
"GET /addresses/:addr/outputs/siacoin": wrapPublicAuthHandler(srv.addressesAddrOutputsSCHandler),
"GET /addresses/:addr/outputs/siafund": wrapPublicAuthHandler(srv.addressesAddrOutputsSFHandler),

"GET /outputs/siacoin/:id": wrapPublicAuthHandler(srv.outputsSiacoinHandlerGET),
"GET /outputs/siafund/:id": wrapPublicAuthHandler(srv.outputsSiafundHandlerGET),
"GET /outputs/siacoin/:id": wrapPublicAuthHandler(srv.outputsSiacoinHandlerGET),
"GET /outputs/siacoin/:id/spent": wrapPublicAuthHandler(srv.outputsSiacoinSpentHandlerGET),
"GET /outputs/siafund/:id": wrapPublicAuthHandler(srv.outputsSiafundHandlerGET),
"GET /outputs/siafund/:id/spent": wrapPublicAuthHandler(srv.outputsSiafundSpentHandlerGET),

"GET /events/:id": wrapPublicAuthHandler(srv.eventsHandlerGET),

Expand Down
Loading

0 comments on commit ef1886e

Please sign in to comment.