Skip to content

Commit 86b2632

Browse files
authored
[ship-3742] Improve CCIP reporting (#1653)
* Adds atlas client * Adds changeset * Fix linting issues
1 parent f0edbec commit 86b2632

File tree

3 files changed

+317
-0
lines changed

3 files changed

+317
-0
lines changed

lib/.changeset/v1.51.1.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Adds Atlas client and tests

lib/client/atlas.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// atlas_client.go
2+
package client
3+
4+
import (
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
"github.com/go-resty/resty/v2"
13+
14+
"github.com/smartcontractkit/chainlink-testing-framework/lib/logging"
15+
)
16+
17+
// AtlasClient defines the structure of the Atlas service client.
18+
type AtlasClient struct {
19+
BaseURL string
20+
RestClient *resty.Client
21+
Logger logging.Logger
22+
}
23+
24+
// TransactionHash defines a single transaction hash.
25+
type TransactionHash struct {
26+
MessageID string `json:"messageId"`
27+
}
28+
29+
// TransactionResponse defines the response format from Atlas for transaction details.
30+
type TransactionResponse struct {
31+
TransactionHash []TransactionHash `json:"transactionHash"`
32+
}
33+
34+
// NewAtlasClient creates a new Atlas client instance.
35+
func NewAtlasClient(baseURL string) *AtlasClient {
36+
logging.Init()
37+
logger := logging.GetLogger(nil, "ATLAS_CLIENT_LOG_LEVEL")
38+
logger.Info().
39+
Str("BaseURL", baseURL).
40+
Msg("Initializing Atlas Client")
41+
42+
isDebug := os.Getenv("RESTY_DEBUG") == "true"
43+
restyClient := resty.New().SetDebug(isDebug)
44+
45+
return &AtlasClient{
46+
BaseURL: baseURL,
47+
RestClient: restyClient,
48+
Logger: logger,
49+
}
50+
}
51+
52+
// GetTransactionDetails retrieves transaction details using the provided msgIdOrTxnHash.
53+
func (ac *AtlasClient) GetTransactionDetails(msgIdOrTxnHash string) (*TransactionResponse, error) {
54+
endpoint := fmt.Sprintf("%s/atlas/search?msgIdOrTxnHash=%s", ac.BaseURL, msgIdOrTxnHash)
55+
ac.Logger.Info().
56+
Str("msgIdOrTxnHash", msgIdOrTxnHash).
57+
Msg("Sending request to fetch transaction details")
58+
59+
resp, err := ac.RestClient.R().
60+
SetHeader("Content-Type", "application/json").
61+
Get(endpoint)
62+
if err != nil {
63+
ac.Logger.Error().Err(err).Msg("Failed to send request")
64+
return nil, err
65+
}
66+
67+
if resp.StatusCode() != 200 {
68+
ac.Logger.Error().Int("statusCode", resp.StatusCode()).Msg("Received non-OK status")
69+
return nil, errors.New("failed to retrieve transaction details")
70+
}
71+
72+
var transactionResponse TransactionResponse
73+
ac.Logger.Debug().Bytes("Response", resp.Body())
74+
if err := json.Unmarshal(resp.Body(), &transactionResponse); err != nil {
75+
ac.Logger.Error().Err(err).Msg("Failed to unmarshal response")
76+
return nil, err
77+
}
78+
79+
ac.Logger.Info().
80+
Str("transactionHash", msgIdOrTxnHash).
81+
Msg("Successfully retrieved transaction details")
82+
return &transactionResponse, nil
83+
}
84+
85+
// CustomTime wraps time.Time to support custom unmarshaling.
86+
type CustomTime struct {
87+
time.Time
88+
}
89+
90+
// UnmarshalJSON parses a timestamp string into a CustomTime.
91+
func (ct *CustomTime) UnmarshalJSON(b []byte) error {
92+
s := strings.Trim(string(b), `"`)
93+
if s == "null" || s == "" {
94+
ct.Time = time.Time{}
95+
return nil
96+
}
97+
t, err := time.Parse("2006-01-02T15:04:05", s)
98+
if err != nil {
99+
return err
100+
}
101+
ct.Time = t
102+
return nil
103+
}
104+
105+
// TransactionDetails represents detailed transaction data returned by Atlas.
106+
type TransactionDetails struct {
107+
MessageId *string `json:"messageId"`
108+
State *int `json:"state"`
109+
Votes *int `json:"votes"`
110+
SourceNetworkName *string `json:"sourceNetworkName"`
111+
DestNetworkName *string `json:"destNetworkName"`
112+
CommitBlockTimestamp *CustomTime `json:"commitBlockTimestamp"`
113+
Root *string `json:"root"`
114+
SendFinalized *CustomTime `json:"sendFinalized"`
115+
CommitStore *string `json:"commitStore"`
116+
Origin *string `json:"origin"`
117+
SequenceNumber *int `json:"sequenceNumber"`
118+
Sender *string `json:"sender"`
119+
Receiver *string `json:"receiver"`
120+
SourceChainId *string `json:"sourceChainId"`
121+
DestChainId *string `json:"destChainId"`
122+
RouterAddress *string `json:"routerAddress"`
123+
OnrampAddress *string `json:"onrampAddress"`
124+
OfframpAddress *string `json:"offrampAddress"`
125+
DestRouterAddress *string `json:"destRouterAddress"`
126+
SendTransactionHash *string `json:"sendTransactionHash"`
127+
SendTimestamp *CustomTime `json:"sendTimestamp"`
128+
SendBlock *int `json:"sendBlock"`
129+
SendLogIndex *int `json:"sendLogIndex"`
130+
Min *string `json:"min"`
131+
Max *string `json:"max"`
132+
CommitTransactionHash *string `json:"commitTransactionHash"`
133+
CommitBlockNumber *int `json:"commitBlockNumber"`
134+
CommitLogIndex *int `json:"commitLogIndex"`
135+
Arm *string `json:"arm"`
136+
BlessTransactionHash *string `json:"blessTransactionHash"`
137+
BlessBlockNumber *int `json:"blessBlockNumber"`
138+
BlessBlockTimestamp *CustomTime `json:"blessBlockTimestamp"`
139+
BlessLogIndex *int `json:"blessLogIndex"`
140+
ReceiptTransactionHash *string `json:"receiptTransactionHash"`
141+
ReceiptTimestamp *CustomTime `json:"receiptTimestamp"`
142+
ReceiptBlock *int `json:"receiptBlock"`
143+
ReceiptLogIndex *int `json:"receiptLogIndex"`
144+
ReceiptFinalized *CustomTime `json:"receiptFinalized"`
145+
Data *string `json:"data"`
146+
Strict *bool `json:"strict"`
147+
Nonce *int `json:"nonce"`
148+
FeeToken *string `json:"feeToken"`
149+
GasLimit *string `json:"gasLimit"`
150+
FeeTokenAmount *string `json:"feeTokenAmount"`
151+
TokenAmounts *[]string `json:"tokenAmounts"`
152+
}
153+
154+
// GetMessageDetails fetches detailed transaction info using the message endpoint.
155+
func (ac *AtlasClient) GetMessageDetails(messageID string) (*TransactionDetails, error) {
156+
endpoint := fmt.Sprintf("%s/atlas/message/%s", ac.BaseURL, messageID)
157+
ac.Logger.Info().
158+
Str("messageID", messageID).
159+
Msg("Sending request to fetch message details")
160+
161+
resp, err := ac.RestClient.R().
162+
SetHeader("Content-Type", "application/json").
163+
Get(endpoint)
164+
if err != nil {
165+
ac.Logger.Error().Err(err).Msg("Failed to send request for message details")
166+
return nil, err
167+
}
168+
169+
if resp.StatusCode() != 200 {
170+
ac.Logger.Error().Int("statusCode", resp.StatusCode()).Msg("Received non-OK status for message details")
171+
return nil, fmt.Errorf("failed to retrieve message details, status code: %d", resp.StatusCode())
172+
}
173+
174+
body := resp.Body()
175+
if string(body) == "Message not found" {
176+
ac.Logger.Warn().
177+
Str("messageID", messageID).
178+
Msg("Message not found in Atlas")
179+
return nil, errors.New("message not found")
180+
}
181+
182+
var details TransactionDetails
183+
ac.Logger.Debug().Bytes("Response", body)
184+
if err := json.Unmarshal(body, &details); err != nil {
185+
ac.Logger.Error().Err(err).Msg("Failed to unmarshal message details")
186+
return nil, err
187+
}
188+
189+
return &details, nil
190+
}

lib/client/atlas_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// atlas_test.go
2+
package client
3+
4+
import (
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
13+
"github.com/smartcontractkit/chainlink-testing-framework/lib/utils/ptr"
14+
)
15+
16+
// TestAtlasClient_GetTransactionDetails_Success verifies that GetTransactionDetails successfully parses a valid response.
17+
func TestAtlasClient_GetTransactionDetails_Success(t *testing.T) {
18+
// Create a mock Atlas server
19+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
// Check that the endpoint contains "/atlas/search"
21+
assert.Contains(t, r.URL.String(), "/atlas/search")
22+
w.WriteHeader(http.StatusOK)
23+
// Return a valid JSON response
24+
response := `{"transactionHash": [{"messageId": "abc123"}]}`
25+
_, err := w.Write([]byte(response))
26+
assert.NoError(t, err)
27+
}))
28+
defer mockServer.Close()
29+
30+
// Create the Atlas client using the mock server URL
31+
client := NewAtlasClient(mockServer.URL)
32+
33+
// Call GetTransactionDetails
34+
resp, err := client.GetTransactionDetails("abc123")
35+
assert.NoError(t, err)
36+
assert.NotNil(t, resp)
37+
assert.Len(t, resp.TransactionHash, 1)
38+
assert.Equal(t, "abc123", resp.TransactionHash[0].MessageID)
39+
}
40+
41+
// TestAtlasClient_GetTransactionDetails_NonOK verifies that a non-200 response results in an error.
42+
func TestAtlasClient_GetTransactionDetails_NonOK(t *testing.T) {
43+
// Create a mock Atlas server that returns a non-OK status code
44+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+
assert.Contains(t, r.URL.String(), "/atlas/search")
46+
w.WriteHeader(http.StatusBadRequest)
47+
}))
48+
defer mockServer.Close()
49+
50+
client := NewAtlasClient(mockServer.URL)
51+
resp, err := client.GetTransactionDetails("abc123")
52+
assert.Nil(t, resp)
53+
assert.Error(t, err)
54+
}
55+
56+
// TestAtlasClient_GetMessageDetails_Success verifies that GetMessageDetails correctly unmarshals a valid response.
57+
func TestAtlasClient_GetMessageDetails_Success(t *testing.T) {
58+
// Prepare a minimal valid response for TransactionDetails.
59+
detailsResponse := TransactionDetails{
60+
MessageId: ptr.Ptr("msg123"),
61+
State: ptr.Ptr(1),
62+
Votes: ptr.Ptr(5),
63+
SourceNetworkName: ptr.Ptr("source"),
64+
DestNetworkName: ptr.Ptr("dest"),
65+
}
66+
responseBytes, err := json.Marshal(detailsResponse)
67+
assert.NoError(t, err)
68+
69+
// Create a mock server returning the above JSON.
70+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+
assert.Equal(t, "/atlas/message/msg123", r.URL.Path)
72+
w.WriteHeader(http.StatusOK)
73+
_, err := w.Write(responseBytes)
74+
assert.NoError(t, err)
75+
}))
76+
defer mockServer.Close()
77+
78+
client := NewAtlasClient(mockServer.URL)
79+
details, err := client.GetMessageDetails("msg123")
80+
assert.NoError(t, err)
81+
assert.NotNil(t, details)
82+
assert.Equal(t, "msg123", *details.MessageId)
83+
assert.Equal(t, "source", *details.SourceNetworkName)
84+
assert.Equal(t, "dest", *details.DestNetworkName)
85+
}
86+
87+
// TestAtlasClient_GetMessageDetails_MessageNotFound checks that a "Message not found" response is handled correctly.
88+
func TestAtlasClient_GetMessageDetails_MessageNotFound(t *testing.T) {
89+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90+
assert.Equal(t, "/atlas/message/msg123", r.URL.Path)
91+
w.WriteHeader(http.StatusOK)
92+
_, err := w.Write([]byte("Message not found"))
93+
assert.NoError(t, err)
94+
}))
95+
defer mockServer.Close()
96+
97+
client := NewAtlasClient(mockServer.URL)
98+
details, err := client.GetMessageDetails("msg123")
99+
assert.Nil(t, details)
100+
assert.Error(t, err)
101+
assert.Equal(t, "message not found", err.Error())
102+
}
103+
104+
// TestAtlasClient_GetMessageDetails_NonOK verifies that a non-200 status code leads to an error.
105+
func TestAtlasClient_GetMessageDetails_NonOK(t *testing.T) {
106+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+
assert.Equal(t, "/atlas/message/msg123", r.URL.Path)
108+
w.WriteHeader(http.StatusNotFound)
109+
}))
110+
defer mockServer.Close()
111+
112+
client := NewAtlasClient(mockServer.URL)
113+
details, err := client.GetMessageDetails("msg123")
114+
assert.Nil(t, details)
115+
assert.Error(t, err)
116+
assert.Contains(t, err.Error(), "failed to retrieve message details")
117+
}
118+
119+
// TestAtlasClient_DebugMode verifies that the RESTY_DEBUG environment variable enables debug mode in the Resty client.
120+
func TestAtlasClient_DebugMode(t *testing.T) {
121+
os.Setenv("RESTY_DEBUG", "true")
122+
defer os.Unsetenv("RESTY_DEBUG")
123+
124+
client := NewAtlasClient("http://example.com")
125+
assert.True(t, client.RestClient.Debug)
126+
}

0 commit comments

Comments
 (0)