Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions accounts/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,22 @@ type (
ActiveAccounts uint64
FullStorageAccounts uint64
}

// FundingEvent represents a single funding event for an account on a host.
FundingEvent struct {
ID int64
AccountKey proto.Account
HostKey types.PublicKey
ContractID types.FileContractID
AmountSC types.Currency
EstimatedUploadBytes uint64
EstimatedDownloadBytes uint64
CreatedAt time.Time
}

// FundingCursor is used to paginate through funding events.
FundingCursor struct {
After time.Time `json:"after"`
ID int64 `json:"id"`
}
)
13 changes: 13 additions & 0 deletions accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type (
HostAccountsForFunding(hk types.PublicKey, quotaName string, threshold time.Time, limit int) ([]HostAccount, error)
ScheduleAccountsForFunding(hostKey types.PublicKey) error
UpdateHostAccounts(accounts []HostAccount) error
RecordFundingEvents(events []FundingEvent) error
FundingEvents(cursor FundingCursor, limit int) ([]FundingEvent, error)

ValidAppConnectKey(string) error
AppConnectKeyUserSecret(string) (secret types.Hash256, err error)
Expand Down Expand Up @@ -153,6 +155,17 @@ func (m *AccountManager) UpdateHostAccounts(accounts []HostAccount) error {
return m.store.UpdateHostAccounts(accounts)
}

// RecordFundingEvents records the given funding events.
func (m *AccountManager) RecordFundingEvents(events []FundingEvent) error {
return m.store.RecordFundingEvents(events)
}

// FundingEvents returns a list of funding events starting after the given
// cursor.
func (m *AccountManager) FundingEvents(cursor FundingCursor, limit int) ([]FundingEvent, error) {
return m.store.FundingEvents(cursor, limit)
}

// ServiceAccounts returns all registered service accounts for a given host.
func (m *AccountManager) ServiceAccounts(hk types.PublicKey) []HostAccount {
m.serviceAccountsMu.Lock()
Expand Down
25 changes: 25 additions & 0 deletions api/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type (
AccountStats() (accounts.AccountStats, error)
AppStats(offset, limit int) ([]accounts.AppStats, error)
ConnectKeyStats() (accounts.ConnectKeyStats, error)
FundingEvents(cursor accounts.FundingCursor, limit int) ([]accounts.FundingEvent, error)
}

// SlabManager defines the slab-related interface used by the admin API.
Expand Down Expand Up @@ -277,6 +278,9 @@ func NewAPI(chain ChainManager, accounts Accounts, contracts ContractManager, ho
"GET /quotas/:key": a.handleGETQuota,
"DELETE /quotas/:key": a.handleDELETEQuota,

// funding endpoints
"GET /funding/events": a.handleGETFundingEvents,

// wallet endpoints
"GET /wallet": a.handleGETWallet,
"GET /wallet/events": a.handleGETWalletEvents,
Expand Down Expand Up @@ -594,6 +598,27 @@ func (a *admin) handleDELETEQuota(jc jape.Context) {
}
}

func (a *admin) handleGETFundingEvents(jc jape.Context) {
_, limit, ok := api.ParseOffsetLimit(jc)
if !ok {
return
}

var cursor accounts.FundingCursor
if jc.DecodeForm("after", &cursor.After) != nil {
return
}
if jc.DecodeForm("id", &cursor.ID) != nil {
return
}

events, err := a.accounts.FundingEvents(cursor, limit)
if !a.checkServerError(jc, "failed to get funding events", err) {
return
}
jc.Encode(events)
}

func (a *admin) handleGETAccount(jc jape.Context) {
var ak types.PublicKey
if jc.DecodeParam("accountkey", &ak) != nil {
Expand Down
94 changes: 94 additions & 0 deletions api/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1720,6 +1720,100 @@ func TestPruneAccounts(t *testing.T) {
}
}

func TestFundingEventsAPI(t *testing.T) {
cluster := testutils.NewCluster(t, testutils.WithHosts(1))
indexer := cluster.Indexer
adminClient := indexer.Admin

cluster.WaitForContracts(t)
sk := cluster.AddAccount(t)
cluster.WaitForAccountFunding(t, proto.Account(sk.PublicKey()))

events, err := adminClient.FundingEvents(context.Background(), accounts.FundingCursor{}, 100)
if err != nil {
t.Fatalf("failed to get funding events: %v", err)
}
if len(events) == 0 {
t.Fatal("expected funding events after account funding")
}

ev := events[0]
if ev.AmountSC.IsZero() {
t.Fatal("expected non-zero AmountSC")
}
if ev.EstimatedUploadBytes == 0 {
t.Fatal("expected non-zero EstimatedUploadBytes")
}
if ev.EstimatedDownloadBytes == 0 {
t.Fatal("expected non-zero EstimatedDownloadBytes")
}
if ev.CreatedAt.IsZero() {
t.Fatal("expected non-zero CreatedAt")
}
}

func TestFundingEventsAPICursorPagination(t *testing.T) {
cluster := testutils.NewCluster(t, testutils.WithHosts(1))
indexer := cluster.Indexer
adminClient := indexer.Admin

cluster.WaitForContracts(t)

// Add multiple accounts to generate multiple funding events
numAccounts := 3
for range numAccounts {
sk := cluster.AddAccount(t)
cluster.WaitForAccountFunding(t, proto.Account(sk.PublicKey()))
}

// Page through funding events with limit=1
var allEvents []accounts.FundingEvent
cursor := accounts.FundingCursor{}

for {
page, err := adminClient.FundingEvents(context.Background(), cursor, 1)
if err != nil {
t.Fatalf("failed to get funding events page: %v", err)
}
if len(page) == 0 {
break
}
allEvents = append(allEvents, page...)
last := page[len(page)-1]
cursor = accounts.FundingCursor{After: last.CreatedAt, ID: last.ID}
}

if len(allEvents) == 0 {
t.Fatal("expected funding events after account funding")
}

// Verify ascending order by CreatedAt
for i := range allEvents {
if i == 0 {
continue
}
if allEvents[i].CreatedAt.Before(allEvents[i-1].CreatedAt) {
t.Fatalf("events not in ascending order at index %d", i)
}
}

// Verify no duplicate IDs
seen := make(map[int64]bool)
for _, ev := range allEvents {
if seen[ev.ID] {
t.Fatalf("duplicate event ID %d", ev.ID)
}
seen[ev.ID] = true
}

// Verify events have valid fields
for _, ev := range allEvents {
if ev.CreatedAt.IsZero() {
t.Fatal("expected non-zero CreatedAt")
}
}
}

// newTestLogger creates a console logger used for testing.
func newTestLogger(enable bool) *zap.Logger {
if !enable {
Expand Down
11 changes: 11 additions & 0 deletions api/admin/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"sync"
"time"

"go.sia.tech/core/consensus"
proto "go.sia.tech/core/rhp/v4"
Expand Down Expand Up @@ -163,6 +164,16 @@ func (c *Client) Accounts(ctx context.Context, opts ...api.URLQueryParameterOpti
return
}

// FundingEvents returns funding events using cursor-based pagination.
func (c *Client) FundingEvents(ctx context.Context, cursor accounts.FundingCursor, limit int) (events []accounts.FundingEvent, err error) {
values := url.Values{}
values.Set("limit", fmt.Sprintf("%d", limit))
values.Set("after", cursor.After.Format(time.RFC3339Nano))
values.Set("id", fmt.Sprintf("%d", cursor.ID))
err = c.c.GET(ctx, "/funding/events?"+values.Encode(), &events)
return
}

// Alerts returns registered alerts.
func (c *Client) Alerts(ctx context.Context, opts ...AlertQueryParameterOption) (alerts []alerts.Alert, err error) {
values := url.Values{}
Expand Down
3 changes: 3 additions & 0 deletions contracts/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ var (

type CandidateContract = candidateContract

// NewCandidateContract creates a CandidateContract for testing purposes.
//
//revive:disable:unexported-return Intentionally returns unexported type alias for test access
func NewCandidateContract(goodForAppend, goodForFunding, goodForRefresh error) CandidateContract {
return CandidateContract{
goodForAppend: goodForAppend,
Expand Down
8 changes: 6 additions & 2 deletions contracts/fund_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,16 @@ func (am *accountsManagerMock) UpdateServiceAccounts(accs []accounts.HostAccount
return nil
}

func (am *accountsManagerMock) RecordFundingEvents(events []accounts.FundingEvent) error {
return nil
}

type accountFunderMock struct {
mu sync.Mutex
calls []fundAccountsCall
}

func (f *accountFunderMock) FundAccounts(ctx context.Context, host hosts.Host, contractIDs []types.FileContractID, accs []accounts.HostAccount, target types.Currency, log *zap.Logger) (funded int, drained int, err error) {
func (f *accountFunderMock) FundAccounts(ctx context.Context, host hosts.Host, contractIDs []types.FileContractID, accs []accounts.HostAccount, target types.Currency, log *zap.Logger) (funded int, drained int, deposits []contracts.FundedDeposit, err error) {
f.mu.Lock()
defer f.mu.Unlock()
accsCopy := make([]accounts.HostAccount, len(accs))
Expand All @@ -97,7 +101,7 @@ func (f *accountFunderMock) FundAccounts(ctx context.Context, host hosts.Host, c
accounts: accsCopy,
target: target,
})
return len(accs), 0, nil
return len(accs), 0, nil, nil
}

func TestPerformAccountFunding(t *testing.T) {
Expand Down
24 changes: 18 additions & 6 deletions contracts/funder.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import (
)

type (
// FundedDeposit pairs a deposit with the contract that funded it.
FundedDeposit struct {
ContractID types.FileContractID
Deposit proto.AccountDeposit
}

// FunderHostClient defines the interface for the funder to interact with the
// host.
FunderHostClient interface {
Expand Down Expand Up @@ -51,14 +57,14 @@ func NewFunder(client FunderHostClient, cl *ContractLocker, rev *RevisionManager
// the number of contracts that were drained. Consecutive calls for the same
// host should take this into account and adjust the contract IDs that are being
// passed in.
func (f *Funder) FundAccounts(ctx context.Context, host hosts.Host, contractIDs []types.FileContractID, accs []accounts.HostAccount, target types.Currency, log *zap.Logger) (funded int, drained int, _ error) {
func (f *Funder) FundAccounts(ctx context.Context, host hosts.Host, contractIDs []types.FileContractID, accs []accounts.HostAccount, target types.Currency, log *zap.Logger) (funded int, drained int, deposits []FundedDeposit, _ error) {
// sanity check the input
if len(accs) > proto.MaxAccountBatchSize {
return 0, 0, errors.New("too many accounts")
return 0, 0, nil, errors.New("too many accounts")
} else if len(contractIDs) == 0 {
return 0, 0, errors.New("no contract provided")
return 0, 0, nil, errors.New("no contract provided")
} else if len(accs) == 0 {
return 0, 0, nil
return 0, 0, nil, nil
}

// prepare account keys
Expand Down Expand Up @@ -98,6 +104,12 @@ func (f *Funder) FundAccounts(ctx context.Context, host hosts.Host, contractIDs
return rhp.ContractRevision{}, proto.Usage{}, err
}
funded = maxEnd
for _, d := range res.Deposits {
deposits = append(deposits, FundedDeposit{
ContractID: contractID,
Deposit: d,
})
}
return rhp.ContractRevision{
ID: contractID,
Revision: res.Revision,
Expand All @@ -124,11 +136,11 @@ func (f *Funder) FundAccounts(ctx context.Context, host hosts.Host, contractIDs
return funded == len(accountKeys), nil
}()
if err != nil {
return 0, 0, err
return 0, 0, nil, err
} else if done {
break
}
}

return funded, drained, nil
return funded, drained, deposits, nil
}
12 changes: 6 additions & 6 deletions contracts/funder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func TestFunder(t *testing.T) {
f := NewFunder(hc, cl, rev, nil, &mockChainManager{}, zap.NewNop())

// assert contract is marked as drained if it is out of funds
funded, drained, err := f.FundAccounts(context.Background(), host, []types.FileContractID{{1}}, accs, target, zap.NewNop())
funded, drained, _, err := f.FundAccounts(context.Background(), host, []types.FileContractID{{1}}, accs, target, zap.NewNop())
if err != nil {
t.Fatal(err)
} else if funded != 0 {
Expand All @@ -126,7 +126,7 @@ func TestFunder(t *testing.T) {
}

// assert contract is marked as drained if it is not revisable
funded, drained, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{2}}, accs, target, zap.NewNop())
funded, drained, _, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{2}}, accs, target, zap.NewNop())
if err != nil {
t.Fatal(err)
} else if funded != 0 {
Expand All @@ -136,7 +136,7 @@ func TestFunder(t *testing.T) {
}

// assert contract is not marked as drained if replenish RPC fails
funded, drained, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{3}}, accs, target, zap.NewNop())
funded, drained, _, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{3}}, accs, target, zap.NewNop())
if err != nil {
t.Fatal(err)
} else if funded != 0 {
Expand All @@ -146,7 +146,7 @@ func TestFunder(t *testing.T) {
}

// assert contract is marked as drained if replenish RPC succeeds but leaves the contract with insufficient funds afterwards
funded, drained, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{4}}, accs, target, zap.NewNop())
funded, drained, _, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{4}}, accs, target, zap.NewNop())
if err != nil {
t.Fatal(err)
} else if funded != 1 {
Expand All @@ -156,7 +156,7 @@ func TestFunder(t *testing.T) {
}

// assert contracts are iterated and funded is updated until we run out of contracts
funded, drained, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{5}, {6}}, accs, target, zap.NewNop())
funded, drained, _, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{5}, {6}}, accs, target, zap.NewNop())
if err != nil {
t.Fatal(err)
} else if funded != 2 {
Expand All @@ -166,7 +166,7 @@ func TestFunder(t *testing.T) {
}

// assert contracts are iterated and funded is updated until we run out of accounts
funded, drained, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{7}, {1}, {5}, {4}}, accs, target, zap.NewNop())
funded, drained, _, err = f.FundAccounts(context.Background(), host, []types.FileContractID{{7}, {1}, {5}, {4}}, accs, target, zap.NewNop())
if err != nil {
t.Fatal(err)
} else if funded != 3 {
Expand Down
Loading
Loading