Skip to content

Commit 1cd3f5f

Browse files
authored
feat(api): transfer history + outgoing/expired endpoints (#331)
* feat(api): transfer to external party via party id Enable sending a token to a Canton party that is not registered in the middleware (e.g. a party on an external participant node), addressed by its party id rather than an EVM address. - Non-custodial: PrepareRequest accepts an optional to_party_id; when set, Prepare uses it directly and skips the userstore recipient lookup. Reuses the existing /prepare + /execute flow. - Custodial: new POST /api/v2/transfer/custodial does a single server-signed transfer via TransferByPartyID (the middleware holds the user's key). - Shared validatePartyID helper (hint::fingerprint) and self-transfer guard. Authorization/whitelist policy and outgoing-offer tracking are out of scope (tracked separately). * fix(api): reject ambiguous recipient in Prepare at the service layer Address review feedback: Prepare now explicitly rejects a request that sets both (or neither) of `to` and `to_party_id` instead of silently preferring `to_party_id`. The HTTP handler already enforced this; this adds the same guard at the service boundary for direct callers. * feat(indexer): move storage to generalized indexer_transfers table Introduce a single indexer_transfers table holding both direct (atomic CIP-56) and offer-based (2-step, e.g. USDCx) transfers with a mutable status lifecycle, and retire indexer_pending_offers. The indexer's external API is unchanged: the existing pending-offers endpoints are served by a thin compatibility shim over the new table, so the api-server and custodial accept worker are untouched (API modernization is a follow-up PR). - Migration 7: create indexer_transfers, backfill history from the offers table + the TRANSFER event log, then drop indexer_pending_offers. No data lost; the two sources are disjoint so nothing is double-counted. - Processor: offer CREATE -> insert pending transfer; ARCHIVE -> complete; CIP-56 TRANSFER event -> upsert a direct (completed) transfer. Balances are untouched (still maintained from the event/holding paths). - Store: new transfer write methods; ListPendingOffersForParty / ListAllPendingOffers reimplemented over indexer_transfers, returning the existing PendingOffer shape. * refactor(indexer): address review on transfers PR - Remove the unused Transfer.Archived field (the offer decoder uses PendingOffer.IsArchived in this PR). - Document why the migration loads rows in memory (the per-participant transfer history is small) and note the INSERT…SELECT alternative if it ever grows. - Add composite (from_party_id, created_at) / (to_party_id, created_at) indexes so the list-by-party-newest-first queries avoid a filesort. * refactor(indexer): single InsertTransfer; review fixes on transfers PR Address PR review: - Consolidate to one write method: InsertTransfer now records a transfer with the caller-provided Kind/Status (offer/pending or direct/completed) instead of two methods that each forced them. Removes UpsertDirectTransfer. The ON CONFLICT (contract_id) DO NOTHING makes it idempotent for ledger-stream replays (the indexer re-reads from the last saved offset on restart). - migration 5: clearer comment on legacyOffer — PendingOfferDao was removed, the struct stays here for backward compat so the historical migrations can manage the legacy table with bun. - migration 7: order the backfill by ledger_offset (canonical monotonic sequence) instead of created_at (an effective-time that can tie). * feat(api): transfer history + outgoing/expired endpoints on indexer_transfers Re-synced on the updated storage PR (#330): inherits the single InsertTransfer write method (no UpsertDirectTransfer) and the migration fixes. - Indexer: /parties/{id}/pending-offers -> /parties/{id}/transfers?role=&status= + /pending-transfers; ListTransfers is a single-table query with native pagination and a derived "expired" status. Removes the PendingOffer compat shim; decoder/processor now produce Transfer. - api-server: /api/v2/transfer/incoming (unchanged shape) + /outgoing and /completed. - Accept worker reads GetPendingTransfers, oldest-pending first (FIFO). * docs(indexer): fix stale ListPendingTransfers order comment The doc comment still said 'created_at DESC' from before the FIFO change; the query is created_at ASC (oldest first). Also reword the inline comment to be precise — it's FIFO (longest-pending first), which correlates with nearest expiry rather than strictly ordering by it. * test(indexer): mirror migration 7 composite indexes in pg_test setup
1 parent ab4863d commit 1cd3f5f

29 files changed

Lines changed: 1282 additions & 868 deletions

pkg/app/api/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func (s *Server) Run() error {
115115
logger.Info("Connected to Canton")
116116

117117
// The indexer is now a hard dependency: the token provider (when in
118-
// indexer mode), the accept worker, and the pending-offers list endpoint
118+
// indexer mode), the accept worker, and the transfers list endpoints
119119
// all read from it. Build the HTTP client once and share — separate
120120
// instances would just open redundant idle connections to the same host.
121121
indexerClient, err := buildIndexerClient(cfg, reg)
@@ -185,7 +185,7 @@ func (s *Server) Run() error {
185185
// the dual configuration exists for historical reasons and is consolidated
186186
// here so the rest of the code never has to pick between them.
187187
//
188-
// An indexer is now required (the pending-offers endpoint backs onto it), so
188+
// An indexer is now required (the transfer list/history endpoints back onto it), so
189189
// startup fails fast if neither location is configured. The returned client is
190190
// wrapped with metrics so all outbound indexer calls are observed.
191191
func buildIndexerClient(cfg *config.APIServer, reg sharedmetrics.NamespacedRegisterer) (indexerclient.Client, error) {

pkg/custodial/accept_worker.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,13 @@ func (w *AcceptWorker) acceptPending(ctx context.Context) {
115115

116116
page := 1
117117
for {
118-
result, err := w.indexer.GetAllPendingOffers(ctx, indexer.Pagination{
118+
result, err := w.indexer.GetPendingTransfers(ctx, indexer.Pagination{
119119
Page: page,
120120
Limit: acceptWorkerPageLimit,
121121
})
122122
if err != nil {
123123
w.metrics.ErrorsTotal.WithLabelValues("fetch_offers").Inc()
124-
w.logger.Warn("accept worker: failed to fetch pending offers", zap.Error(err))
124+
w.logger.Warn("accept worker: failed to fetch pending transfers", zap.Error(err))
125125
return
126126
}
127127

@@ -134,35 +134,36 @@ func (w *AcceptWorker) acceptPending(ctx context.Context) {
134134
w.metrics.OffersFetchedTotal.Add(float64(len(result.Items)))
135135

136136
for i := range result.Items {
137-
offer := result.Items[i]
138-
if !custodialParties[offer.ReceiverPartyID] {
137+
transfer := result.Items[i]
138+
// ToPartyID is the offer receiver — the custodial party that must accept.
139+
if !custodialParties[transfer.ToPartyID] {
139140
continue
140141
}
141142
acceptStart := time.Now()
142143
err := w.cantonToken.AcceptTransferInstruction(
143-
ctx, offer.ReceiverPartyID, offer.ContractID, offer.InstrumentAdmin,
144+
ctx, transfer.ToPartyID, transfer.ContractID, transfer.InstrumentAdmin,
144145
)
145146
w.metrics.OfferAcceptDuration.Observe(time.Since(acceptStart).Seconds())
146147
if err != nil {
147148
w.metrics.OffersAcceptedTotal.WithLabelValues("error").Inc()
148149
w.logger.Warn("accept worker: failed to accept offer",
149-
zap.String("party_id", offer.ReceiverPartyID),
150-
zap.String("contract_id", offer.ContractID),
150+
zap.String("party_id", transfer.ToPartyID),
151+
zap.String("contract_id", transfer.ContractID),
151152
zap.Error(err),
152153
)
153154
continue
154155
}
155156
w.metrics.OffersAcceptedTotal.WithLabelValues("success").Inc()
156157
w.logger.Info("accept worker: accepted transfer offer",
157-
zap.String("party_id", offer.ReceiverPartyID),
158-
zap.String("contract_id", offer.ContractID),
159-
zap.String("sender", offer.SenderPartyID),
160-
zap.String("amount", offer.Amount),
158+
zap.String("party_id", transfer.ToPartyID),
159+
zap.String("contract_id", transfer.ContractID),
160+
zap.String("sender", transfer.FromPartyID),
161+
zap.String("amount", transfer.Amount),
161162
)
162163
}
163164

164165
if int64(page*acceptWorkerPageLimit) >= result.Total {
165-
// Reached the end of the pending-offers stream — cycle completed
166+
// Reached the end of the pending-transfers stream — cycle completed
166167
// without an unrecoverable error. Per-offer failures don't count
167168
// against the cycle's success: those are tracked in
168169
// OffersAcceptedTotal{result="error"}.

pkg/custodial/accept_worker_test.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,22 @@ func custodialUser() *user.User {
2828

2929
const testInstrumentAdmin = "admin-party::zzz"
3030

31-
func pendingOffer(contractID string) indexer.PendingOffer {
32-
return indexer.PendingOffer{
31+
func pendingOffer(contractID string) indexer.Transfer {
32+
return indexer.Transfer{
3333
ContractID: contractID,
34-
Status: indexer.OfferStatusPending,
35-
ReceiverPartyID: "custodial-party::abc",
36-
SenderPartyID: "sender-party::xyz",
34+
Kind: indexer.TransferKindOffer,
35+
Status: indexer.TransferStatusPending,
36+
ToPartyID: "custodial-party::abc",
37+
FromPartyID: "sender-party::xyz",
3738
InstrumentAdmin: testInstrumentAdmin,
3839
InstrumentID: "USDCX",
3940
Amount: "100.0",
4041
LedgerOffset: 42,
4142
}
4243
}
4344

44-
func allOffersPage(offers ...indexer.PendingOffer) *indexer.Page[indexer.PendingOffer] {
45-
return &indexer.Page[indexer.PendingOffer]{
45+
func allOffersPage(offers ...indexer.Transfer) *indexer.Page[indexer.Transfer] {
46+
return &indexer.Page[indexer.Transfer]{
4647
Items: offers,
4748
Total: int64(len(offers)),
4849
Page: 1,
@@ -71,7 +72,7 @@ func TestAcceptWorker_AcceptsSingleOffer(t *testing.T) {
7172
page := allOffersPage(offer)
7273

7374
lister.EXPECT().ListCustodialUsers(mock.Anything).Return([]*user.User{custodialUser()}, nil)
74-
ic.EXPECT().GetAllPendingOffers(mock.Anything, indexer.Pagination{Page: 1, Limit: acceptWorkerPageLimit}).
75+
ic.EXPECT().GetPendingTransfers(mock.Anything, indexer.Pagination{Page: 1, Limit: acceptWorkerPageLimit}).
7576
Return(page, nil)
7677
tok.EXPECT().AcceptTransferInstruction(mock.Anything, "custodial-party::abc", "contract-1", testInstrumentAdmin).
7778
Return(nil)
@@ -86,19 +87,19 @@ func TestAcceptWorker_SkipsOffersForUnregisteredParties(t *testing.T) {
8687
ic := indexermocks.NewClient(t)
8788

8889
// Offer for a party not in our custodialParties map
89-
unregisteredOffer := indexer.PendingOffer{
90+
unregisteredOffer := indexer.Transfer{
9091
ContractID: "contract-unknown",
91-
ReceiverPartyID: "unknown-party::xyz",
92+
ToPartyID: "unknown-party::xyz",
9293
InstrumentAdmin: testInstrumentAdmin,
9394
}
9495
custodialOffer := pendingOffer("contract-custodial")
95-
page := &indexer.Page[indexer.PendingOffer]{
96-
Items: []indexer.PendingOffer{unregisteredOffer, custodialOffer},
96+
page := &indexer.Page[indexer.Transfer]{
97+
Items: []indexer.Transfer{unregisteredOffer, custodialOffer},
9798
Total: 2, Page: 1, Limit: acceptWorkerPageLimit,
9899
}
99100

100101
lister.EXPECT().ListCustodialUsers(mock.Anything).Return([]*user.User{custodialUser()}, nil)
101-
ic.EXPECT().GetAllPendingOffers(mock.Anything, mock.Anything).Return(page, nil)
102+
ic.EXPECT().GetPendingTransfers(mock.Anything, mock.Anything).Return(page, nil)
102103
// Only the registered custodial offer should be accepted
103104
tok.EXPECT().AcceptTransferInstruction(mock.Anything, "custodial-party::abc", "contract-custodial", testInstrumentAdmin).
104105
Return(nil)
@@ -114,13 +115,13 @@ func TestAcceptWorker_LogsAndContinuesOnAcceptError(t *testing.T) {
114115

115116
offer1 := pendingOffer("contract-1")
116117
offer2 := pendingOffer("contract-2")
117-
page := &indexer.Page[indexer.PendingOffer]{
118-
Items: []indexer.PendingOffer{offer1, offer2},
118+
page := &indexer.Page[indexer.Transfer]{
119+
Items: []indexer.Transfer{offer1, offer2},
119120
Total: 2, Page: 1, Limit: acceptWorkerPageLimit,
120121
}
121122

122123
lister.EXPECT().ListCustodialUsers(mock.Anything).Return([]*user.User{custodialUser()}, nil)
123-
ic.EXPECT().GetAllPendingOffers(mock.Anything, mock.Anything).Return(page, nil)
124+
ic.EXPECT().GetPendingTransfers(mock.Anything, mock.Anything).Return(page, nil)
124125
tok.EXPECT().AcceptTransferInstruction(mock.Anything, mock.Anything, "contract-1", mock.Anything).
125126
Return(errors.New("ALREADY_EXISTS"))
126127
tok.EXPECT().AcceptTransferInstruction(mock.Anything, mock.Anything, "contract-2", mock.Anything).
@@ -136,7 +137,7 @@ func TestAcceptWorker_LogsAndContinuesOnIndexerError(t *testing.T) {
136137
ic := indexermocks.NewClient(t)
137138

138139
lister.EXPECT().ListCustodialUsers(mock.Anything).Return([]*user.User{custodialUser()}, nil)
139-
ic.EXPECT().GetAllPendingOffers(mock.Anything, mock.Anything).
140+
ic.EXPECT().GetPendingTransfers(mock.Anything, mock.Anything).
140141
Return(nil, errors.New("indexer unavailable"))
141142

142143
worker := NewAcceptWorker(tok, lister, ic, time.Hour, NewNopMetrics(), zap.NewNop())
@@ -176,13 +177,13 @@ func TestAcceptWorker_PaginatesAllOffers(t *testing.T) {
176177
offer2 := pendingOffer("contract-2")
177178

178179
// Total is 400, limit 200 → 2 pages
179-
page1 := &indexer.Page[indexer.PendingOffer]{Items: []indexer.PendingOffer{offer1}, Total: 400, Page: 1, Limit: acceptWorkerPageLimit}
180-
page2 := &indexer.Page[indexer.PendingOffer]{Items: []indexer.PendingOffer{offer2}, Total: 400, Page: 2, Limit: acceptWorkerPageLimit}
180+
page1 := &indexer.Page[indexer.Transfer]{Items: []indexer.Transfer{offer1}, Total: 400, Page: 1, Limit: acceptWorkerPageLimit}
181+
page2 := &indexer.Page[indexer.Transfer]{Items: []indexer.Transfer{offer2}, Total: 400, Page: 2, Limit: acceptWorkerPageLimit}
181182

182183
lister.EXPECT().ListCustodialUsers(mock.Anything).Return([]*user.User{custodialUser()}, nil)
183-
ic.EXPECT().GetAllPendingOffers(mock.Anything, indexer.Pagination{Page: 1, Limit: acceptWorkerPageLimit}).
184+
ic.EXPECT().GetPendingTransfers(mock.Anything, indexer.Pagination{Page: 1, Limit: acceptWorkerPageLimit}).
184185
Return(page1, nil)
185-
ic.EXPECT().GetAllPendingOffers(mock.Anything, indexer.Pagination{Page: 2, Limit: acceptWorkerPageLimit}).
186+
ic.EXPECT().GetPendingTransfers(mock.Anything, indexer.Pagination{Page: 2, Limit: acceptWorkerPageLimit}).
186187
Return(page2, nil)
187188
tok.EXPECT().AcceptTransferInstruction(mock.Anything, mock.Anything, "contract-1", mock.Anything).Return(nil)
188189
tok.EXPECT().AcceptTransferInstruction(mock.Anything, mock.Anything, "contract-2", mock.Anything).Return(nil)

pkg/custodial/metrics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type Metrics struct {
3333

3434
// ErrorsTotal counts cycles that aborted early.
3535
// phase=list_users – ListCustodialUsers failed
36-
// phase=fetch_offers – GetAllPendingOffers failed on some page
36+
// phase=fetch_offers – GetPendingTransfers failed on some page
3737
// Per-offer accept failures are *not* counted here (see OffersAccepted).
3838
ErrorsTotal *prometheus.CounterVec
3939

pkg/indexer/client/http.go

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ type Client interface {
5050
p indexer.Pagination,
5151
) (*indexer.Page[*indexer.ParsedEvent], error)
5252

53-
// Pending inbound transfers
54-
GetPendingOffersForParty(ctx context.Context, partyID string, p indexer.Pagination) (*indexer.Page[indexer.PendingOffer], error)
55-
GetAllPendingOffers(ctx context.Context, p indexer.Pagination) (*indexer.Page[indexer.PendingOffer], error)
53+
// Transfers
54+
GetTransfers(
55+
ctx context.Context, partyID string, query indexer.TransferQuery, p indexer.Pagination,
56+
) (*indexer.Page[indexer.Transfer], error)
57+
GetPendingTransfers(ctx context.Context, p indexer.Pagination) (*indexer.Page[indexer.Transfer], error)
5658
}
5759

5860
// HTTP implements Client by calling the indexer's unauthenticated admin HTTP API.
@@ -193,26 +195,35 @@ func (c *HTTP) ListPartyEvents(
193195
return &page, nil
194196
}
195197

196-
// GetPendingOffersForParty calls GET /indexer/v1/admin/parties/{partyID}/pending-offers.
197-
func (c *HTTP) GetPendingOffersForParty(
198-
ctx context.Context, partyID string, p indexer.Pagination,
199-
) (*indexer.Page[indexer.PendingOffer], error) {
200-
u := c.partyBase(partyID) + "/pending-offers?" + pageQuery(p).Encode()
201-
var page indexer.Page[indexer.PendingOffer]
198+
// GetTransfers calls GET /indexer/v1/admin/parties/{partyID}/transfers,
199+
// filtering by role (receiver/sender/any) and status (pending/expired/completed;
200+
// "" = all).
201+
func (c *HTTP) GetTransfers(
202+
ctx context.Context, partyID string, query indexer.TransferQuery, p indexer.Pagination,
203+
) (*indexer.Page[indexer.Transfer], error) {
204+
q := pageQuery(p)
205+
if query.Role != "" {
206+
q.Set("role", string(query.Role))
207+
}
208+
if query.Status != "" {
209+
q.Set("status", query.Status)
210+
}
211+
u := c.partyBase(partyID) + "/transfers?" + q.Encode()
212+
var page indexer.Page[indexer.Transfer]
202213
if err := c.getJSON(ctx, u, &page); err != nil {
203-
return nil, fmt.Errorf("pending offers for party %s: %w", partyID, err)
214+
return nil, fmt.Errorf("transfers for party %s: %w", partyID, err)
204215
}
205216
return &page, nil
206217
}
207218

208-
// GetAllPendingOffers calls GET /indexer/v1/admin/pending-offers.
209-
func (c *HTTP) GetAllPendingOffers(
219+
// GetPendingTransfers calls GET /indexer/v1/admin/pending-transfers.
220+
func (c *HTTP) GetPendingTransfers(
210221
ctx context.Context, p indexer.Pagination,
211-
) (*indexer.Page[indexer.PendingOffer], error) {
212-
u := c.baseURL + "/indexer/v1/admin/pending-offers?" + pageQuery(p).Encode()
213-
var page indexer.Page[indexer.PendingOffer]
222+
) (*indexer.Page[indexer.Transfer], error) {
223+
u := c.baseURL + "/indexer/v1/admin/pending-transfers?" + pageQuery(p).Encode()
224+
var page indexer.Page[indexer.Transfer]
214225
if err := c.getJSON(ctx, u, &page); err != nil {
215-
return nil, fmt.Errorf("get all pending offers: %w", err)
226+
return nil, fmt.Errorf("get pending transfers: %w", err)
216227
}
217228
return &page, nil
218229
}

pkg/indexer/client/instrumented.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,20 @@ func (c *InstrumentedClient) ListPartyEvents(
111111
return page, err
112112
}
113113

114-
func (c *InstrumentedClient) GetPendingOffersForParty(
115-
ctx context.Context, partyID string, p indexer.Pagination,
116-
) (*indexer.Page[indexer.PendingOffer], error) {
117-
defer c.observe(OpGetPendingOffersForPty)()
118-
page, err := c.inner.GetPendingOffersForParty(ctx, partyID, p)
119-
c.incErr(OpGetPendingOffersForPty, err)
114+
func (c *InstrumentedClient) GetTransfers(
115+
ctx context.Context, partyID string, query indexer.TransferQuery, p indexer.Pagination,
116+
) (*indexer.Page[indexer.Transfer], error) {
117+
defer c.observe(OpGetTransfers)()
118+
page, err := c.inner.GetTransfers(ctx, partyID, query, p)
119+
c.incErr(OpGetTransfers, err)
120120
return page, err
121121
}
122122

123-
func (c *InstrumentedClient) GetAllPendingOffers(
123+
func (c *InstrumentedClient) GetPendingTransfers(
124124
ctx context.Context, p indexer.Pagination,
125-
) (*indexer.Page[indexer.PendingOffer], error) {
126-
defer c.observe(OpGetAllPendingOffers)()
127-
page, err := c.inner.GetAllPendingOffers(ctx, p)
128-
c.incErr(OpGetAllPendingOffers, err)
125+
) (*indexer.Page[indexer.Transfer], error) {
126+
defer c.observe(OpGetPendingTransfers)()
127+
page, err := c.inner.GetPendingTransfers(ctx, p)
128+
c.incErr(OpGetPendingTransfers, err)
129129
return page, err
130130
}

pkg/indexer/client/metrics.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@ import (
1111
type ClientOperation string
1212

1313
const (
14-
OpGetToken ClientOperation = "get_token"
15-
OpListTokens ClientOperation = "list_tokens"
16-
OpTotalSupply ClientOperation = "total_supply"
17-
OpGetBalance ClientOperation = "get_balance"
18-
OpListBalancesForParty ClientOperation = "list_balances_for_party"
19-
OpListBalancesForToken ClientOperation = "list_balances_for_token"
20-
OpGetEvent ClientOperation = "get_event"
21-
OpListTokenEvents ClientOperation = "list_token_events"
22-
OpListPartyEvents ClientOperation = "list_party_events"
23-
OpGetPendingOffersForPty ClientOperation = "get_pending_offers_for_party"
24-
OpGetAllPendingOffers ClientOperation = "get_all_pending_offers"
14+
OpGetToken ClientOperation = "get_token"
15+
OpListTokens ClientOperation = "list_tokens"
16+
OpTotalSupply ClientOperation = "total_supply"
17+
OpGetBalance ClientOperation = "get_balance"
18+
OpListBalancesForParty ClientOperation = "list_balances_for_party"
19+
OpListBalancesForToken ClientOperation = "list_balances_for_token"
20+
OpGetEvent ClientOperation = "get_event"
21+
OpListTokenEvents ClientOperation = "list_token_events"
22+
OpListPartyEvents ClientOperation = "list_party_events"
23+
OpGetTransfers ClientOperation = "get_transfers"
24+
OpGetPendingTransfers ClientOperation = "get_pending_transfers"
2525
)
2626

2727
// Metrics holds Prometheus collectors for the indexer HTTP client.

0 commit comments

Comments
 (0)