Skip to content

Commit 54ccf49

Browse files
authored
Add cctp v2 client (#1344)
* Add CCTPv2 HTTP client * Add unit tests * Add tests for parseResponseBody * fix lint errors * Add links to CCTPv2 API docs * Add httpStatus to metrics
1 parent d0d81df commit 54ccf49

File tree

2 files changed

+854
-0
lines changed

2 files changed

+854
-0
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Package v2 provides an HTTP client wrapper for Circle's CCTP v2 attestation API.
2+
// This package handles the HTTP communication layer for fetching CCTP v2 messages
3+
// and attestations from Circle's API endpoints.
4+
// The CCTPv2 "get messages" API is documented here:
5+
// https://developers.circle.com/api-reference/cctp/all/get-messages-v-2
6+
package v2
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"net/http"
13+
"net/url"
14+
"strconv"
15+
"strings"
16+
"time"
17+
18+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
19+
cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccipocr3"
20+
21+
httputil "github.com/smartcontractkit/chainlink-ccip/execute/tokendata/http"
22+
"github.com/smartcontractkit/chainlink-ccip/pluginconfig"
23+
)
24+
25+
const (
26+
apiVersionV2 = "v2"
27+
messagesPath = "messages"
28+
)
29+
30+
type CCTPv2HTTPClient interface {
31+
GetMessages(
32+
ctx context.Context, sourceChain cciptypes.ChainSelector, sourceDomainID uint32, transactionHash string,
33+
) (CCTPv2Messages, error)
34+
}
35+
36+
// MetricsReporter provides metrics reporting for attestation API calls
37+
type MetricsReporter interface {
38+
TrackAttestationAPILatency(
39+
sourceChain cciptypes.ChainSelector, sourceDomain uint32, success bool, httpStatus string, latency time.Duration)
40+
}
41+
42+
// CCTPv2HTTPClientImpl implements CCTPv2AttestationClient using HTTP calls to Circle's attestation API
43+
type CCTPv2HTTPClientImpl struct {
44+
lggr logger.Logger
45+
client httputil.HTTPClient
46+
metricsReporter MetricsReporter
47+
}
48+
49+
// NewCCTPv2Client creates a new HTTP-based CCTP v2 attestation client
50+
func NewCCTPv2Client(
51+
lggr logger.Logger,
52+
config pluginconfig.USDCCCTPObserverConfig,
53+
metricsReporter MetricsReporter,
54+
) (*CCTPv2HTTPClientImpl, error) {
55+
if lggr == nil {
56+
return nil, fmt.Errorf("logger cannot be nil")
57+
}
58+
if metricsReporter == nil {
59+
return nil, fmt.Errorf("metricsReporter cannot be nil")
60+
}
61+
62+
client, err := httputil.GetHTTPClient(
63+
lggr,
64+
config.AttestationAPI,
65+
config.AttestationAPIInterval.Duration(),
66+
config.AttestationAPITimeout.Duration(),
67+
config.AttestationAPICooldown.Duration(),
68+
)
69+
if err != nil {
70+
return nil, fmt.Errorf("create HTTP client: %w", err)
71+
}
72+
return &CCTPv2HTTPClientImpl{
73+
lggr: lggr,
74+
client: client,
75+
metricsReporter: metricsReporter,
76+
}, nil
77+
}
78+
79+
// GetMessages fetches CCTP v2 messages and attestations for the given transaction.
80+
func (c *CCTPv2HTTPClientImpl) GetMessages(
81+
ctx context.Context,
82+
sourceChain cciptypes.ChainSelector,
83+
sourceDomainID uint32,
84+
transactionHash string,
85+
) (CCTPv2Messages, error) {
86+
startTime := time.Now()
87+
success := false
88+
httpStatus := ""
89+
90+
defer func() {
91+
latency := time.Since(startTime)
92+
c.metricsReporter.TrackAttestationAPILatency(sourceChain, sourceDomainID, success, httpStatus, latency)
93+
}()
94+
95+
// Validate transaction hash
96+
if transactionHash == "" {
97+
return CCTPv2Messages{}, fmt.Errorf("transaction hash cannot be empty")
98+
}
99+
if !strings.HasPrefix(transactionHash, "0x") || len(transactionHash) != 66 {
100+
return CCTPv2Messages{}, fmt.Errorf("invalid transaction hash format: %s", transactionHash)
101+
}
102+
103+
path := fmt.Sprintf("%s/%s/%d?transactionHash=%s",
104+
apiVersionV2, messagesPath, sourceDomainID, url.QueryEscape(transactionHash))
105+
body, status, err := c.client.Get(ctx, path)
106+
httpStatus = strconv.Itoa(int(status))
107+
108+
if err != nil {
109+
return CCTPv2Messages{},
110+
fmt.Errorf("http call failed to get CCTPv2 messages for sourceDomainID %d and transactionHash %s, error: %w",
111+
sourceDomainID, transactionHash, err)
112+
}
113+
114+
if status != http.StatusOK {
115+
c.lggr.Warnw(
116+
"Non-200 status from Circle API",
117+
"status", status,
118+
"path", path,
119+
"sourceDomainID", sourceDomainID,
120+
"transactionHash", transactionHash,
121+
"responseBody", string(body),
122+
)
123+
return CCTPv2Messages{}, fmt.Errorf(
124+
"circle API returned status %d for path %s", status, path)
125+
}
126+
127+
result, err := parseResponseBody(body)
128+
if err != nil {
129+
return CCTPv2Messages{}, err
130+
}
131+
132+
success = true
133+
return result, nil
134+
}
135+
136+
// parseResponseBody parses the JSON response from Circle's attestation API
137+
// and returns a CCTPv2Messages struct containing the decoded CCTP v2 messages.
138+
func parseResponseBody(body cciptypes.Bytes) (CCTPv2Messages, error) {
139+
var messages CCTPv2Messages
140+
if err := json.Unmarshal(body, &messages); err != nil {
141+
return CCTPv2Messages{}, fmt.Errorf("failed to decode json: %w", err)
142+
}
143+
return messages, nil
144+
}
145+
146+
// CCTPv2Messages represents the response structure from Circle's attestation API,
147+
// containing a list of CCTP v2 messages with their attestations.
148+
// This API response type is documented here:
149+
// https://developers.circle.com/api-reference/cctp/all/get-messages-v-2
150+
type CCTPv2Messages struct {
151+
Messages []CCTPv2Message `json:"messages"`
152+
}
153+
154+
// CCTPv2Message represents a single CCTP v2 message from Circle's attestation API.
155+
// It contains the message data, attestation signature, and decoded message details
156+
// needed for cross-chain USDC transfers.
157+
type CCTPv2Message struct {
158+
Message string `json:"message"`
159+
EventNonce string `json:"eventNonce"`
160+
Attestation string `json:"attestation"`
161+
DecodedMessage CCTPv2DecodedMessage `json:"decodedMessage"`
162+
CCTPVersion int `json:"cctpVersion"`
163+
Status string `json:"status"`
164+
}
165+
166+
// CCTPv2DecodedMessage represents the 'decodedMessage' object within a CCTPv2Message.
167+
type CCTPv2DecodedMessage struct {
168+
SourceDomain string `json:"sourceDomain"`
169+
DestinationDomain string `json:"destinationDomain"`
170+
Nonce string `json:"nonce"`
171+
Sender string `json:"sender"`
172+
Recipient string `json:"recipient"`
173+
DestinationCaller string `json:"destinationCaller"`
174+
MessageBody string `json:"messageBody"`
175+
DecodedMessageBody CCTPv2DecodedMessageBody `json:"decodedMessageBody"`
176+
// The following fields are optional.
177+
MinFinalityThreshold string `json:"minFinalityThreshold,omitempty"`
178+
FinalityThresholdExecuted string `json:"finalityThresholdExecuted,omitempty"`
179+
}
180+
181+
// CCTPv2DecodedMessageBody represents the 'decodedMessageBody' object within a CCTPv2DecodedMessage.
182+
type CCTPv2DecodedMessageBody struct {
183+
BurnToken string `json:"burnToken"`
184+
MintRecipient string `json:"mintRecipient"`
185+
Amount string `json:"amount"`
186+
MessageSender string `json:"messageSender"`
187+
// The following fields are optional.
188+
MaxFee string `json:"maxFee,omitempty"`
189+
FeeExecuted string `json:"feeExecuted,omitempty"`
190+
ExpirationBlock string `json:"expirationBlock,omitempty"`
191+
HookData string `json:"hookData,omitempty"`
192+
}

0 commit comments

Comments
 (0)