Skip to content

Commit 63fb005

Browse files
authored
feat(sdk-go): view bridge transaction history (#176)
* feat(sdk-go): view bridge transaction history * chore: apply suggestion
1 parent d2c9a19 commit 63fb005

File tree

12 files changed

+364
-7
lines changed

12 files changed

+364
-7
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package contractsapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/pkg/errors"
7+
"github.com/trufnetwork/sdk-go/core/types"
8+
)
9+
10+
// GetHistory retrieves the transaction history for a wallet on a specific bridge
11+
func (s *Action) GetHistory(ctx context.Context, input types.GetHistoryInput) ([]types.BridgeHistory, error) {
12+
if input.BridgeIdentifier == "" {
13+
return nil, errors.New("bridge identifier is required")
14+
}
15+
if input.Wallet == "" {
16+
return nil, errors.New("wallet address is required")
17+
}
18+
19+
limit := 20
20+
if input.Limit != nil {
21+
if *input.Limit < 0 {
22+
return nil, errors.New("limit must be non-negative")
23+
}
24+
limit = *input.Limit
25+
if limit > 100 {
26+
limit = 100
27+
}
28+
}
29+
offset := 0
30+
if input.Offset != nil {
31+
if *input.Offset < 0 {
32+
return nil, errors.New("offset must be non-negative")
33+
}
34+
offset = *input.Offset
35+
}
36+
37+
actionName := input.BridgeIdentifier + "_get_history"
38+
39+
// Arguments for the action: $wallet_address, $limit, $offset
40+
args := []any{input.Wallet, limit, offset}
41+
42+
res, err := s.call(ctx, actionName, args)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
return DecodeCallResult[types.BridgeHistory](res)
48+
}

core/contractsapi/decode_call_results.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,40 @@ func mapColumnsToStructFieldsInternal(structElemType reflect.Type, columnNames [
9898
// structInstanceVal is the reflect.Value of the struct instance (not a pointer to it).
9999
// structElemType is the reflect.Type of the struct, for error messages.
100100
// mappings is the output from mapColumnsToStructFieldsInternal.
101-
func prepareScanTargetsForStructInternal(structInstanceVal reflect.Value, structElemType reflect.Type, mappings []*fieldMappingInfo) ([]any, error) {
101+
func prepareScanTargetsForStructInternal(structInstanceVal reflect.Value, structElemType reflect.Type, mappings []*fieldMappingInfo, rowSrc []any) ([]any, error) {
102102
numCols := len(mappings)
103103
dstArgs := make([]any, numCols)
104104

105105
for colIdx := 0; colIdx < numCols; colIdx++ {
106106
if targetInfo := mappings[colIdx]; targetInfo != nil {
107107
fieldInStruct := structInstanceVal.Field(targetInfo.StructFieldIndex)
108108
if !fieldInStruct.CanAddr() {
109-
// This should ideally not happen for exported fields of a struct obtained via reflect.New().Elem()
110109
return nil, errors.Errorf("cannot address field %s (index %d) in struct %s", structElemType.Field(targetInfo.StructFieldIndex).Name, targetInfo.StructFieldIndex, structElemType.Name())
111110
}
112-
dstArgs[colIdx] = fieldInStruct.Addr().Interface() // Pointer to field
111+
112+
// Handle pointer fields
113+
if fieldInStruct.Kind() == reflect.Ptr {
114+
// If source value is nil, set field to nil and skip scanning
115+
if rowSrc[colIdx] == nil {
116+
fieldInStruct.Set(reflect.Zero(fieldInStruct.Type()))
117+
dstArgs[colIdx] = new(any) // Dummy target for ScanTo
118+
continue
119+
}
120+
121+
// If field is nil, allocate memory for it
122+
if fieldInStruct.IsNil() {
123+
fieldInStruct.Set(reflect.New(fieldInStruct.Type().Elem()))
124+
}
125+
// Pass the pointer to the underlying value (e.g., *int64) to ScanTo
126+
// fieldInStruct is *int64, fieldInStruct.Interface() returns that *int64
127+
dstArgs[colIdx] = fieldInStruct.Interface()
128+
} else {
129+
// Non-pointer field, pass address of the field
130+
dstArgs[colIdx] = fieldInStruct.Addr().Interface()
131+
}
113132
} else {
114133
// For columns in QueryResult that don't map to any field in T, scan into a dummy var.
115-
dstArgs[colIdx] = new(any) // Or use &sql.RawBytes{} or similar if specific discard behavior is needed
134+
dstArgs[colIdx] = new(any)
116135
}
117136
}
118137
return dstArgs, nil
@@ -192,7 +211,7 @@ func DecodeCallResult[T any](result *kwiltypes.QueryResult) ([]T, error) {
192211
// Get the actual struct value (MyStruct) to access its fields
193212
itemStructVal := itemContainer.Elem()
194213

195-
dstArgs, err := prepareScanTargetsForStructInternal(itemStructVal, elementType, mappings)
214+
dstArgs, err := prepareScanTargetsForStructInternal(itemStructVal, elementType, mappings, rowSrc)
196215
if err != nil {
197216
return nil, errors.Wrapf(err, "failed to prepare scan targets for struct %s", elementType.Name())
198217
}

core/tnclient/actions_transport.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ func (a *TransportAction) BatchFilterStreamsByExistence(ctx context.Context, str
170170
return nil, fmt.Errorf("BatchFilterStreamsByExistence not implemented for custom transports - use HTTP transport or implement if needed")
171171
}
172172

173+
func (a *TransportAction) GetHistory(ctx context.Context, input clientType.GetHistoryInput) ([]clientType.BridgeHistory, error) {
174+
return nil, fmt.Errorf("GetHistory not implemented for custom transports - use HTTP transport or implement if needed")
175+
}
176+
173177
// TransportPrimitiveAction implements IPrimitiveAction interface using the Transport abstraction.
174178
// This allows primitive stream actions to work with any transport (HTTP, CRE, etc.).
175179
//

core/tnclient/client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,12 @@ func (c *Client) BatchFilterStreamsByExistence(ctx context.Context, streams []cl
289289
}
290290
return actions.BatchFilterStreamsByExistence(ctx, streams, returnExisting)
291291
}
292+
293+
// GetHistory retrieves the transaction history for a wallet on a specific bridge
294+
func (c *Client) GetHistory(ctx context.Context, input clientType.GetHistoryInput) ([]clientType.BridgeHistory, error) {
295+
actions, err := c.LoadActions()
296+
if err != nil {
297+
return nil, errors.Wrap(err, "failed to load actions for GetHistory")
298+
}
299+
return actions.GetHistory(ctx, input)
300+
}

core/types/bridge_types.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package types
2+
3+
// BridgeHistory represents a transaction history record from the bridge extension.
4+
type BridgeHistory struct {
5+
Type string `json:"type"`
6+
Amount string `json:"amount"` // NUMERIC(78,0) as string
7+
FromAddress []byte `json:"from_address"`
8+
ToAddress []byte `json:"to_address"`
9+
InternalTxHash []byte `json:"internal_tx_hash"`
10+
ExternalTxHash []byte `json:"external_tx_hash"`
11+
Status string `json:"status"`
12+
BlockHeight int64 `json:"block_height"`
13+
BlockTimestamp int64 `json:"block_timestamp"`
14+
ExternalBlockHeight *int64 `json:"external_block_height"`
15+
}
16+
17+
// GetHistoryInput is input for GetHistory
18+
type GetHistoryInput struct {
19+
BridgeIdentifier string `validate:"required"`
20+
Wallet string `validate:"required"`
21+
Limit *int `validate:"omitempty,min=1"`
22+
Offset *int `validate:"omitempty,min=0"`
23+
}

core/types/stream.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,7 @@ type IAction interface {
124124
// BatchFilterStreamsByExistence filters a list of streams based on their existence in the database.
125125
// Use this instead of BatchStreamExists if you want less data returned.
126126
BatchFilterStreamsByExistence(ctx context.Context, streamsInput []StreamLocator, returnExisting bool) ([]StreamLocator, error)
127+
128+
// GetHistory retrieves the transaction history for a wallet on a specific bridge
129+
GetHistory(ctx context.Context, input GetHistoryInput) ([]BridgeHistory, error)
127130
}

core/types/tsn_client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ type Client interface {
4747
// BatchFilterStreamsByExistence filters a list of streams based on their existence in the database.
4848
// Use this instead of BatchStreamExists if you want less data returned.
4949
BatchFilterStreamsByExistence(ctx context.Context, streams []StreamLocator, returnExisting bool) ([]StreamLocator, error)
50+
// GetHistory retrieves the transaction history for a wallet on a specific bridge
51+
GetHistory(ctx context.Context, input GetHistoryInput) ([]BridgeHistory, error)
5052
}

docs/api-reference.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,54 @@ for _, row := range result.Values {
978978
}
979979
```
980980

981+
#### `GetHistory`
982+
983+
```go
984+
GetHistory(ctx context.Context, input types.GetHistoryInput) ([]types.BridgeHistory, error)
985+
```
986+
987+
Retrieves the transaction history for a wallet on a specific bridge.
988+
989+
**Parameters:**
990+
991+
- `ctx`: Operation context.
992+
- `input`: Input containing:
993+
- `BridgeIdentifier`: The unique identifier of the bridge (e.g., "hoodi_tt2")
994+
- `Wallet`: The wallet address to query
995+
- `Limit`: Max number of records to return (optional, default 20)
996+
- `Offset`: Number of records to skip (optional, default 0)
997+
998+
**Returns:**
999+
1000+
- `[]types.BridgeHistory`: List of history records
1001+
- `error`: Error if query fails
1002+
1003+
**Example:**
1004+
1005+
```go
1006+
history, err := client.GetHistory(ctx, types.GetHistoryInput{
1007+
BridgeIdentifier: "hoodi_tt2",
1008+
Wallet: "0x...",
1009+
})
1010+
```
1011+
1012+
#### `BridgeHistory` Struct
1013+
1014+
```go
1015+
type BridgeHistory struct {
1016+
Type string `json:"type"` // "deposit" or "withdrawal"
1017+
Amount string `json:"amount"` // NUMERIC(78,0) as string
1018+
FromAddress []byte `json:"from_address"` // Sender address (if available)
1019+
ToAddress []byte `json:"to_address"` // Recipient address
1020+
InternalTxHash []byte `json:"internal_tx_hash"` // Kwil TX hash
1021+
ExternalTxHash []byte `json:"external_tx_hash"` // Ethereum TX hash
1022+
Status string `json:"status"` // "completed", "claimed", "pending_epoch"
1023+
BlockHeight int64 `json:"block_height"` // Kwil block height
1024+
BlockTimestamp int64 `json:"block_timestamp"` // Kwil block timestamp
1025+
ExternalBlockHeight *int64 `json:"external_block_height"` // Ethereum block height
1026+
}
1027+
```
1028+
9811029
### Performance Optimization
9821030

9831031
#### Cache Strategy

examples/history_example/main.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
"text/tabwriter"
9+
"time"
10+
11+
"github.com/trufnetwork/kwil-db/core/crypto"
12+
"github.com/trufnetwork/kwil-db/core/crypto/auth"
13+
"github.com/trufnetwork/sdk-go/core/tnclient"
14+
"github.com/trufnetwork/sdk-go/core/types"
15+
)
16+
17+
func main() {
18+
ctx := context.Background()
19+
20+
// 1. Initialize Client
21+
// Use environment variable for private key to avoid hardcoding
22+
privateKeyHex := os.Getenv("TN_PRIVATE_KEY")
23+
if privateKeyHex == "" {
24+
log.Fatal("TN_PRIVATE_KEY environment variable is required")
25+
}
26+
27+
pk, err := crypto.Secp256k1PrivateKeyFromHex(privateKeyHex)
28+
if err != nil {
29+
log.Fatalf("Failed to parse private key: %v", err)
30+
}
31+
signer := &auth.EthPersonalSigner{Key: *pk}
32+
33+
// Use testnet gateway or local node
34+
endpoint := os.Getenv("TN_GATEWAY_URL")
35+
if endpoint == "" {
36+
endpoint = "https://gateway.testnet.truf.network"
37+
}
38+
39+
client, err := tnclient.NewClient(ctx, endpoint, tnclient.WithSigner(signer))
40+
if err != nil {
41+
log.Fatalf("Failed to create client: %v", err)
42+
}
43+
44+
fmt.Printf("🔄 Transaction History Demo\n")
45+
fmt.Printf("===========================\n")
46+
fmt.Printf("Endpoint: %s\n", endpoint)
47+
addr := client.Address()
48+
fmt.Printf("Wallet: %s\n\n", addr.Address())
49+
50+
// 2. Define History Query Parameters
51+
// We want history for the "hoodi_tt2" bridge
52+
bridgeID := "hoodi_tt2"
53+
walletAddress := "0xc11Ff6d3cC60823EcDCAB1089F1A4336053851EF" // Example address from issue
54+
limit := 10
55+
offset := 0
56+
57+
fmt.Printf("📋 Fetching history for bridge '%s'...\n", bridgeID)
58+
fmt.Printf(" Wallet: %s\n", walletAddress)
59+
fmt.Printf(" Limit: %d\n", limit)
60+
fmt.Printf(" Offset: %d\n", offset)
61+
fmt.Println("-------------------------------------------------------")
62+
63+
// 3. Call GetHistory
64+
history, err := client.GetHistory(ctx, types.GetHistoryInput{
65+
BridgeIdentifier: bridgeID,
66+
Wallet: walletAddress,
67+
Limit: &limit,
68+
Offset: &offset,
69+
})
70+
if err != nil {
71+
log.Fatalf("❌ Failed to fetch history: %v", err)
72+
}
73+
74+
// 4. Display Results
75+
if len(history) == 0 {
76+
fmt.Println("No history records found.")
77+
return
78+
}
79+
80+
// Helper to format byte slices
81+
formatHexShort := func(b []byte) string {
82+
if len(b) == 0 {
83+
return "null"
84+
}
85+
if len(b) > 4 {
86+
return fmt.Sprintf("0x%x...", b[:4])
87+
}
88+
return fmt.Sprintf("0x%x", b)
89+
}
90+
91+
// Use tabwriter for aligned output
92+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
93+
fmt.Fprintln(w, "TYPE\tAMOUNT\tFROM\tTO\tINTERNAL TX\tEXTERNAL TX\tSTATUS\tBLOCK\tEXT BLOCK\tTIMESTAMP")
94+
fmt.Fprintln(w, "----\t------\t----\t--\t-----------\t-----------\t------\t-----\t---------\t---------")
95+
96+
for _, rec := range history {
97+
// Format timestamp
98+
tm := time.Unix(rec.BlockTimestamp, 0)
99+
timeStr := tm.Format(time.RFC3339)
100+
101+
// Handle nullable external block height
102+
extBlock := "null"
103+
if rec.ExternalBlockHeight != nil {
104+
extBlock = fmt.Sprintf("%d", *rec.ExternalBlockHeight)
105+
}
106+
107+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
108+
rec.Type,
109+
rec.Amount,
110+
formatHexShort(rec.FromAddress),
111+
formatHexShort(rec.ToAddress),
112+
formatHexShort(rec.InternalTxHash),
113+
formatHexShort(rec.ExternalTxHash),
114+
rec.Status,
115+
rec.BlockHeight,
116+
extBlock,
117+
timeStr,
118+
)
119+
}
120+
w.Flush()
121+
122+
fmt.Printf("\n✅ Successfully retrieved %d records.\n", len(history))
123+
fmt.Println("\nNote: 'completed' means credited (deposits) or ready to claim (withdrawals). 'claimed' means withdrawn on Ethereum.")
124+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ require (
1212
github.com/smartcontractkit/cre-sdk-go v1.1.2
1313
github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http v0.10.0
1414
github.com/stretchr/testify v1.11.1
15-
github.com/trufnetwork/kwil-db v0.10.3-0.20260120153326-4fab48fcfa11
16-
github.com/trufnetwork/kwil-db/core v0.4.3-0.20260120153326-4fab48fcfa11
15+
github.com/trufnetwork/kwil-db v0.10.3-0.20260216231327-01b863886682
16+
github.com/trufnetwork/kwil-db/core v0.4.3-0.20260216231327-01b863886682
1717
go.uber.org/zap v1.27.0
1818
google.golang.org/protobuf v1.36.8
1919
)

0 commit comments

Comments
 (0)