Skip to content

Commit 7c63856

Browse files
committed
feat(node): aptos reobserve with endpoint
1 parent 4028572 commit 7c63856

6 files changed

Lines changed: 657 additions & 64 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package aptos
2+
3+
// This file defines the set of Aptos-derived chains supported by the guardian watcher and their native chain IDs.
4+
// The native chain ID is what the node returns in the `chain_id` field of its `/v1` endpoint.
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"math"
11+
"net/http"
12+
"time"
13+
14+
"github.com/certusone/wormhole/node/pkg/common"
15+
"github.com/tidwall/gjson"
16+
"github.com/wormhole-foundation/wormhole/sdk/vaa"
17+
"go.uber.org/zap"
18+
)
19+
20+
type (
21+
// EnvEntry specifies the config data for a given chain / environment.
22+
EnvEntry struct {
23+
// AptosChainID is the expected native chain ID (the `chain_id` field returned by the `/v1` endpoint).
24+
AptosChainID uint64
25+
}
26+
27+
// EnvMap defines the config data for a given environment (mainnet or testnet).
28+
EnvMap map[vaa.ChainID]EnvEntry
29+
)
30+
31+
const (
32+
aptosMainnetChainID uint64 = 1
33+
aptosTestnetChainID uint64 = 2
34+
movementMainnetChainID uint64 = 126
35+
movementTestnetChainID uint64 = 250
36+
37+
chainIDQueryTimeout = 15 * time.Second
38+
)
39+
40+
var (
41+
ErrInvalidEnv = errors.New("invalid environment")
42+
ErrNotFound = errors.New("not found")
43+
44+
mainnetChainConfig = EnvMap{
45+
vaa.ChainIDAptos: {AptosChainID: aptosMainnetChainID},
46+
vaa.ChainIDMovement: {AptosChainID: movementMainnetChainID},
47+
}
48+
49+
testnetChainConfig = EnvMap{
50+
vaa.ChainIDAptos: {AptosChainID: aptosTestnetChainID},
51+
vaa.ChainIDMovement: {AptosChainID: movementTestnetChainID},
52+
}
53+
)
54+
55+
// GetAptosChainID returns the configured native chain ID for the specified environment / chain.
56+
func GetAptosChainID(env common.Environment, chainID vaa.ChainID) (uint64, error) {
57+
m, err := GetChainConfigMap(env)
58+
if err != nil {
59+
return 0, err
60+
}
61+
62+
entry, exists := m[chainID]
63+
if !exists {
64+
return 0, ErrNotFound
65+
}
66+
67+
return entry.AptosChainID, nil
68+
}
69+
70+
// GetChainConfigMap returns the configuration map for the specified environment.
71+
func GetChainConfigMap(env common.Environment) (EnvMap, error) {
72+
if env == common.MainNet {
73+
return mainnetChainConfig, nil
74+
}
75+
76+
if env == common.TestNet {
77+
return testnetChainConfig, nil
78+
}
79+
80+
return EnvMap{}, ErrInvalidEnv
81+
}
82+
83+
// verifyAptosChainID reads the native chain ID from the node and verifies that it matches the expected value
84+
// (making sure we aren't connected to the wrong chain).
85+
func (e *Watcher) verifyAptosChainID(ctx context.Context, logger *zap.Logger, url string) error {
86+
// Don't bother to check in tilt.
87+
if e.env == common.UnsafeDevNet {
88+
return nil
89+
}
90+
91+
expected, err := GetAptosChainID(e.env, e.chainID)
92+
if err != nil {
93+
return fmt.Errorf("failed to look up aptos chain id: %w", err)
94+
}
95+
96+
timeout, cancel := context.WithTimeout(ctx, chainIDQueryTimeout)
97+
defer cancel()
98+
99+
actual, err := queryAptosChainID(timeout, url)
100+
if err != nil {
101+
return err
102+
}
103+
104+
logger.Info("queried aptos chain id", zap.Uint64("expected", expected), zap.Uint64("actual", actual))
105+
106+
if actual != expected {
107+
return fmt.Errorf("aptos chain ID mismatch, expected %d, received %d", expected, actual)
108+
}
109+
110+
return nil
111+
}
112+
113+
// queryAptosChainID queries the specified RPC for the native Aptos chain ID returned by the `/v1` endpoint.
114+
func queryAptosChainID(ctx context.Context, url string) (uint64, error) {
115+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v1", url), nil)
116+
if err != nil {
117+
return 0, fmt.Errorf("failed to build chain id request: %w", err)
118+
}
119+
120+
res, err := http.DefaultClient.Do(req)
121+
if err != nil {
122+
return 0, fmt.Errorf("failed to query aptos chain id: %w", err)
123+
}
124+
defer res.Body.Close()
125+
126+
body, err := common.SafeRead(res.Body)
127+
if err != nil {
128+
return 0, fmt.Errorf("failed to read aptos chain id response: %w", err)
129+
}
130+
131+
if !gjson.Valid(string(body)) {
132+
return 0, fmt.Errorf("invalid JSON in chain id response: %s", string(body))
133+
}
134+
135+
id := gjson.GetBytes(body, "chain_id")
136+
if !id.Exists() {
137+
return 0, fmt.Errorf("chain_id field missing from response")
138+
}
139+
140+
v := id.Uint()
141+
if v == 0 || v > math.MaxUint32 {
142+
return 0, fmt.Errorf("chain_id %d out of expected range", v)
143+
}
144+
return v, nil
145+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package aptos
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/certusone/wormhole/node/pkg/common"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"github.com/wormhole-foundation/wormhole/sdk/vaa"
15+
"go.uber.org/zap"
16+
)
17+
18+
func TestGetChainConfigMap(t *testing.T) {
19+
m, err := GetChainConfigMap(common.MainNet)
20+
require.NoError(t, err)
21+
assert.Equal(t, mainnetChainConfig, m)
22+
23+
m, err = GetChainConfigMap(common.TestNet)
24+
require.NoError(t, err)
25+
assert.Equal(t, testnetChainConfig, m)
26+
27+
_, err = GetChainConfigMap(common.UnsafeDevNet)
28+
require.ErrorIs(t, err, ErrInvalidEnv)
29+
}
30+
31+
func TestGetAptosChainID(t *testing.T) {
32+
tests := []struct {
33+
name string
34+
env common.Environment
35+
chainID vaa.ChainID
36+
want uint64
37+
wantErr error
38+
}{
39+
{"mainnet aptos", common.MainNet, vaa.ChainIDAptos, 1, nil},
40+
{"mainnet movement", common.MainNet, vaa.ChainIDMovement, 126, nil},
41+
{"testnet aptos", common.TestNet, vaa.ChainIDAptos, 2, nil},
42+
{"testnet movement", common.TestNet, vaa.ChainIDMovement, 250, nil},
43+
{"unknown chain", common.MainNet, vaa.ChainIDEthereum, 0, ErrNotFound},
44+
{"invalid env", common.UnsafeDevNet, vaa.ChainIDAptos, 0, ErrInvalidEnv},
45+
}
46+
for _, tc := range tests {
47+
t.Run(tc.name, func(t *testing.T) {
48+
got, err := GetAptosChainID(tc.env, tc.chainID)
49+
if tc.wantErr != nil {
50+
require.ErrorIs(t, err, tc.wantErr)
51+
return
52+
}
53+
require.NoError(t, err)
54+
assert.Equal(t, tc.want, got)
55+
})
56+
}
57+
}
58+
59+
// chainIDServer returns an httptest server that responds to `GET /v1` with body.
60+
func chainIDServer(body string) *httptest.Server {
61+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62+
if r.URL.Path == "/v1" {
63+
fmt.Fprint(w, body)
64+
}
65+
}))
66+
}
67+
68+
func TestVerifyAptosChainID(t *testing.T) {
69+
logger := zap.NewNop()
70+
ctx := context.Background()
71+
72+
t.Run("devnet bypass", func(t *testing.T) {
73+
w := &Watcher{env: common.UnsafeDevNet, chainID: vaa.ChainIDAptos}
74+
require.NoError(t, w.verifyAptosChainID(ctx, logger, "http://unused"))
75+
})
76+
77+
t.Run("unknown chain lookup error", func(t *testing.T) {
78+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDEthereum}
79+
err := w.verifyAptosChainID(ctx, logger, "http://unused")
80+
require.ErrorContains(t, err, "failed to look up aptos chain id")
81+
})
82+
83+
t.Run("request build error", func(t *testing.T) {
84+
// Embedded control character makes the URL fail to parse in net/http.
85+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
86+
err := w.verifyAptosChainID(ctx, logger, "http://example.com\x00")
87+
require.Error(t, err)
88+
assert.Contains(t, err.Error(), "failed to build chain id request")
89+
})
90+
91+
t.Run("connection error", func(t *testing.T) {
92+
// Spin up and immediately close a server to get a guaranteed-unreachable URL.
93+
s := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
94+
s.Close()
95+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
96+
err := w.verifyAptosChainID(ctx, logger, s.URL)
97+
require.Error(t, err)
98+
assert.Contains(t, err.Error(), "failed to query aptos chain id")
99+
})
100+
101+
t.Run("body read error", func(t *testing.T) {
102+
// Server claims a longer Content-Length than it actually writes, then closes the
103+
// connection. The client's read of the body will fail with unexpected EOF.
104+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105+
hj, ok := w.(http.Hijacker)
106+
require.True(t, ok, "server doesn't support hijacking")
107+
conn, _, err := hj.Hijack()
108+
require.NoError(t, err)
109+
defer conn.Close()
110+
_, _ = fmt.Fprint(conn, "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
111+
_, _ = fmt.Fprint(conn, "short")
112+
}))
113+
defer s.Close()
114+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
115+
err := w.verifyAptosChainID(ctx, logger, s.URL)
116+
require.ErrorContains(t, err, "failed to read aptos chain id response")
117+
})
118+
119+
t.Run("invalid JSON", func(t *testing.T) {
120+
s := chainIDServer("not json")
121+
defer s.Close()
122+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
123+
err := w.verifyAptosChainID(ctx, logger, s.URL)
124+
require.ErrorContains(t, err, "invalid JSON")
125+
})
126+
127+
t.Run("chain_id missing", func(t *testing.T) {
128+
s := chainIDServer(`{"epoch":"123"}`)
129+
defer s.Close()
130+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
131+
err := w.verifyAptosChainID(ctx, logger, s.URL)
132+
require.ErrorContains(t, err, "chain_id field missing")
133+
})
134+
135+
t.Run("chain_id zero", func(t *testing.T) {
136+
s := chainIDServer(`{"chain_id":0}`)
137+
defer s.Close()
138+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
139+
err := w.verifyAptosChainID(ctx, logger, s.URL)
140+
require.ErrorContains(t, err, "out of expected range")
141+
})
142+
143+
t.Run("chain_id too large", func(t *testing.T) {
144+
s := chainIDServer(`{"chain_id":4294967296}`) // MaxUint32 + 1
145+
defer s.Close()
146+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
147+
err := w.verifyAptosChainID(ctx, logger, s.URL)
148+
require.ErrorContains(t, err, "out of expected range")
149+
})
150+
151+
t.Run("mismatch", func(t *testing.T) {
152+
s := chainIDServer(`{"chain_id":99}`)
153+
defer s.Close()
154+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
155+
err := w.verifyAptosChainID(ctx, logger, s.URL)
156+
require.ErrorContains(t, err, "mismatch")
157+
})
158+
159+
t.Run("success aptos mainnet", func(t *testing.T) {
160+
s := chainIDServer(`{"chain_id":1}`)
161+
defer s.Close()
162+
w := &Watcher{env: common.MainNet, chainID: vaa.ChainIDAptos}
163+
require.NoError(t, w.verifyAptosChainID(ctx, logger, s.URL))
164+
})
165+
166+
t.Run("success movement testnet", func(t *testing.T) {
167+
s := chainIDServer(`{"chain_id":250}`)
168+
defer s.Close()
169+
w := &Watcher{env: common.TestNet, chainID: vaa.ChainIDMovement}
170+
require.NoError(t, w.verifyAptosChainID(ctx, logger, s.URL))
171+
})
172+
}
173+
174+
// Sanity check: the package-level sentinel errors should be distinct.
175+
func TestSentinelErrors(t *testing.T) {
176+
require.False(t, errors.Is(ErrInvalidEnv, ErrNotFound))
177+
require.False(t, errors.Is(ErrNotFound, ErrInvalidEnv))
178+
}

node/pkg/watchers/aptos/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ func (wc *WatcherConfig) Create(
3333
_ <-chan *query.PerChainQueryInternal,
3434
_ chan<- *query.PerChainQueryResponseInternal,
3535
_ chan<- *common.GuardianSet,
36-
_ common.Environment,
36+
env common.Environment,
3737
) (supervisor.Runnable, interfaces.Reobserver, error) {
38-
return NewWatcher(wc.ChainID, wc.NetworkID, wc.Rpc, wc.Account, wc.Handle, msgC, obsvReqC).Run, nil, nil
38+
watcher := NewWatcher(wc.ChainID, wc.NetworkID, env, wc.Rpc, wc.Account, wc.Handle, msgC, obsvReqC)
39+
return watcher.Run, watcher, nil
3940
}

0 commit comments

Comments
 (0)