Skip to content

Commit 020df64

Browse files
committed
test(evm): reproduce the multi-TMS cross-contamination bug live
Deferred finding LFDT-Panurus#3 (configNetworkResolver.ConfigFor picks one Config for the first TMS to declare an EVM network/channel; Provider memoizes one *Network per (network, channel) with the namespace left out of the key) was confirmed by reading the code, not by running it. This drives the actual Driver.New/Network.Connect/QueryTokens/Broadcast entry points with two TMS on one network, each with its own TokenState address, and observes live that TMS B's reads, its submitter, and its EIP-712 signing domain all silently target TMS A's TokenState. Kept as the regression test for whenever Config gets split into network-shared and per-TMS parts: it currently asserts the contamination and should flip to asserting isolation once that lands. Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
1 parent 4de0b76 commit 020df64

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package evm
8+
9+
// Round 4, target #4: multi-TMS cross-contamination, built and observed live.
10+
//
11+
// This is the regression test for deferred finding #3 (BUG_HUNT_PROMPT.md's "known, deliberately
12+
// deferred" list): configNetworkResolver.ConfigFor (driver.go:479-492) picks one *Config for the
13+
// first TMS to declare an EVM network/channel, and token/services/network/network.go's Provider
14+
// memoizes one *Network per (network, channel) with the namespace NOT part of the memoization key
15+
// (network.go:344-357, 365, 428-448 in the repo root module). Every other TMS sharing that
16+
// network/channel is therefore routed, silently, through the first TMS's Contracts.TokenState,
17+
// EndorsementVerifier/EIP-712 domain, Submitter and Gas policy.
18+
//
19+
// KEEP THIS TEST. It is not a throwaway: it is meant to start failing (RED) the moment the
20+
// eventual fix splits Config into network-shared vs. per-TMS parts, at which point it should be
21+
// updated to assert isolation instead of contamination.
22+
23+
import (
24+
"math/big"
25+
"reflect"
26+
"testing"
27+
"unsafe"
28+
29+
token2 "github.com/LFDT-Panurus/panurus/token"
30+
"github.com/LFDT-Panurus/panurus/token/services/config"
31+
"github.com/LFDT-Panurus/panurus/token/token"
32+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
33+
"github.com/stretchr/testify/assert"
34+
"github.com/stretchr/testify/require"
35+
36+
"github.com/LFDT-Panurus/panurus/x/token/services/network/evm/client"
37+
"github.com/LFDT-Panurus/panurus/x/token/services/network/evm/client/mock"
38+
"github.com/LFDT-Panurus/panurus/x/token/services/network/evm/eip712"
39+
)
40+
41+
// crossTMSResolver simulates configNetworkResolver's real behavior (driver.go:431-497) for two TMS
42+
// sharing one network/channel, each carrying its own EVM configuration with a distinct TokenState.
43+
//
44+
// ConfigFor mirrors configNetworkResolver.ConfigFor's loop exactly: it walks the TMS in declared
45+
// order and returns the FIRST one's *Config for the network/channel -- there is no way for it to
46+
// take a TMS/namespace as input at all, which is the root of the bug. configForCalls counts how many
47+
// times it was asked, so the test can prove Driver.New resolves configuration exactly once for the
48+
// pair, not once per TMS.
49+
type crossTMSResolver struct {
50+
network, channel string
51+
// order lists the TMS ids in the order configNetworkResolver.ConfigFor would encounter them
52+
// while walking config.Service.Configurations(); order[0] is "the first TMS to declare it".
53+
order []token2.TMSID
54+
configs map[token2.TMSID]*Config
55+
configForCalls int
56+
}
57+
58+
func (r *crossTMSResolver) IsEVMNetwork(network, channel string) bool {
59+
return network == r.network && channel == r.channel
60+
}
61+
62+
func (r *crossTMSResolver) TMSIDsFor(network, channel string) []token2.TMSID {
63+
if !r.IsEVMNetwork(network, channel) {
64+
return nil
65+
}
66+
67+
return append([]token2.TMSID(nil), r.order...)
68+
}
69+
70+
func (r *crossTMSResolver) ConfigFor(network, channel string) (*Config, error) {
71+
r.configForCalls++
72+
if !r.IsEVMNetwork(network, channel) {
73+
return nil, errors.Errorf("no evm configuration for [%s:%s]", network, channel)
74+
}
75+
76+
return r.configs[r.order[0]], nil
77+
}
78+
79+
func (r *crossTMSResolver) ConfigurationFor(tmsID token2.TMSID) (*config.Configuration, error) {
80+
return nil, errors.Errorf("no configuration for [%s]", tmsID)
81+
}
82+
83+
// driverNewWithClient reproduces Driver.New's body (driver.go:144-180) line for line, with exactly
84+
// one substitution: the real client.NewJSONRPCClient(config.Endpoint, nil) call is replaced with an
85+
// already-constructed client.EVMClient (a *mock.EVMClient in this test).
86+
//
87+
// New has no seam to inject a client -- it always dials config.Endpoint for real -- and this test
88+
// needs to observe every chain-facing call the constructed Network, Submitter and endorsement
89+
// factory make, which a live TCP dial cannot give it. Every other line -- resolving configuration
90+
// through the resolver, newSubmitter, NewNetwork, installEndorsement, watchPublicParams, the
91+
// recovery-starter wiring -- is copied unchanged from New, and it is exactly that part the test
92+
// exercises; only the transport, which the cross-TMS routing bug has nothing to do with, differs.
93+
func driverNewWithClient(d *Driver, network, channel string, evmClient client.EVMClient) (*Network, error) {
94+
if !d.resolver.IsEVMNetwork(network, channel) {
95+
return nil, errors.Errorf("evm: no evm network configuration for [%s:%s]", network, channel)
96+
}
97+
config, err := d.resolver.ConfigFor(network, channel)
98+
if err != nil {
99+
return nil, err
100+
}
101+
submitter, err := d.newSubmitter(config, evmClient)
102+
if err != nil {
103+
return nil, err
104+
}
105+
n, err := NewNetwork(network, config, evmClient, nil, submitter, d.membership)
106+
if err != nil {
107+
return nil, err
108+
}
109+
if err := d.installEndorsement(n, config, evmClient, network, channel); err != nil {
110+
return nil, err
111+
}
112+
d.watchPublicParams(network, channel, config, evmClient)
113+
n.SetRecoveryStarter(func(ns string) error {
114+
return d.startRecovery(token2.TMSID{Network: network, Channel: channel, Namespace: ns}, n)
115+
})
116+
117+
return n, nil
118+
}
119+
120+
// extractDomain reads the live, unexported eip712.Domain field off a *endorsement.Service returned
121+
// through the EndorsementService interface, via reflection. This is test-only introspection -- no
122+
// production code is touched or needs to be -- used because endorsement.Service.domain has no
123+
// exported accessor and the point of this test is to observe the actual object the driver built for
124+
// TMS B, not to re-derive what it "should" contain.
125+
func extractDomain(t *testing.T, svc EndorsementService) eip712.Domain {
126+
t.Helper()
127+
v := reflect.ValueOf(svc)
128+
require.Equal(t, reflect.Pointer, v.Kind(), "expected a pointer-backed *endorsement.Service")
129+
v = v.Elem()
130+
f := v.FieldByName("domain")
131+
require.True(t, f.IsValid(), "endorsement.Service is expected to carry an unexported 'domain' field")
132+
f = reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()
133+
domain, ok := f.Interface().(eip712.Domain)
134+
require.True(t, ok, "domain field was not an eip712.Domain")
135+
136+
return domain
137+
}
138+
139+
// TestMultiTMSCrossContamination_Live builds two TMS on one EVM network/channel, each with its own,
140+
// different TokenState clone, drives them through the actual production entry points (Driver.New
141+
// once, Network.Connect per TMS -- confirmed below by call-counting rather than assumed), and
142+
// observes -- live, on the constructed objects, not by re-reading source -- which TokenState address
143+
// ends up backing TMS B's reads, its submitter, and its EIP-712 signing domain.
144+
func TestMultiTMSCrossContamination_Live(t *testing.T) {
145+
const network = "evm-net"
146+
const channel = ""
147+
148+
tmsA := token2.TMSID{Network: network, Channel: channel, Namespace: "tms-a"}
149+
tmsB := token2.TMSID{Network: network, Channel: channel, Namespace: "tms-b"}
150+
151+
// Two TMS, two genuinely different TokenState clones. If Config were split per TMS the way the
152+
// eventual fix needs to, these would never be conflated.
153+
addrA, err := client.HexToAddress("0x" + repeat("aa", 20))
154+
require.NoError(t, err)
155+
addrB, err := client.HexToAddress("0x" + repeat("bb", 20))
156+
require.NoError(t, err)
157+
158+
configA := validConfig()
159+
configA.Contracts.TokenState = addrA.Hex()
160+
configA.Submitter = SubmitterConfig{Keystore: writeKey(t, testKeyHex), Address: testKeyAddress}
161+
configA.applyDefaults()
162+
require.NoError(t, configA.Validate())
163+
164+
configB := validConfig()
165+
configB.Contracts.TokenState = addrB.Hex()
166+
// Give B a materially different endorsement policy too (a config for a totally independent
167+
// deployment, not a typo of A's), so nothing about this test depends on A and B being
168+
// accidentally similar.
169+
configB.Endorsement.Endorsers = []EndorserBinding{
170+
{Address: "0x9e5f4552091a69125d5dfcb7b8c2659029395bde", FSCIdentity: "endorser-b"},
171+
}
172+
configB.applyDefaults()
173+
require.NoError(t, configB.Validate())
174+
175+
resolver := &crossTMSResolver{
176+
network: network,
177+
channel: channel,
178+
order: []token2.TMSID{tmsA, tmsB}, // tms-a declares the network first
179+
configs: map[token2.TMSID]*Config{tmsA: configA, tmsB: configB},
180+
}
181+
182+
evmClient := &mock.EVMClient{}
183+
evmClient.ChainIDReturns(big.NewInt(testChainID), nil)
184+
185+
d := &Driver{
186+
resolver: resolver,
187+
identities: fakeIdentityProvider{},
188+
viewManager: fakeViewManager{},
189+
}
190+
191+
// --- Step 1: the real per-(network,channel) entry point --------------------------------------
192+
//
193+
// token/services/network/network.go's Provider memoizes one *Network per (network,channel) via
194+
// lazy.NewProviderWithKeyMapper(key, ms.newNetwork), keyed on netId{network,channel} alone (see
195+
// Provider.networks / netId / key() in that file) -- the TMS/namespace is not part of the key. So
196+
// networkProvider.newNetwork calls d.New(network, channel) exactly ONCE for this pair, no matter
197+
// how many TMS share it, and the resulting *Network is reused by every one of them.
198+
//
199+
// Reproduce that here and confirm it: Driver.New must resolve configuration exactly once.
200+
n, err := driverNewWithClient(d, network, channel, evmClient)
201+
require.NoError(t, err)
202+
require.NotNil(t, n)
203+
assert.Equal(t, 1, resolver.configForCalls,
204+
"Driver.New must resolve the network's configuration exactly once: in production this call "+
205+
"happens once per (network,channel), memoized, regardless of how many TMS share it -- there "+
206+
"is no per-TMS resolution to observe here because none exists")
207+
208+
// --- Step 2: the real per-TMS entry point -----------------------------------------------------
209+
//
210+
// Provider.Connect walks every configured TMS and, for each, calls GetNetwork(tmsID.Network,
211+
// tmsID.Channel).Connect(tmsID.Namespace) -- GetNetwork returns the SAME memoized *Network for
212+
// both TMS A and TMS B, so Connect is what runs per TMS, on one shared object. Network.Connect
213+
// (network.go:140-164) only checks reachability/chain id and starts recovery; it does not
214+
// rebuild, re-scope or namespace anything about the reader, submitter or endorsement factory.
215+
_, err = n.Connect(tmsA.Namespace)
216+
require.NoError(t, err)
217+
_, err = n.Connect(tmsB.Namespace)
218+
require.NoError(t, err)
219+
220+
// --- Observation 1: reads --------------------------------------------------------------------
221+
//
222+
// QueryTokens takes a namespace argument (network.go:359) but never uses it to pick a
223+
// TokenState: it reads through n.reader, built once in NewNetwork from whichever config ConfigFor
224+
// resolved. Ask for TMS B's namespace and see whose contract actually gets called.
225+
evmClient.CallReturns(abiBytes([]byte("owned-by-a")), nil)
226+
out, err := n.QueryTokens(t.Context(), tmsB.Namespace, []*token.ID{{TxId: anchorHex(0x01), Index: 0}})
227+
require.NoError(t, err)
228+
require.Len(t, out, 1)
229+
assert.Equal(t, []byte("owned-by-a"), out[0])
230+
231+
require.Equal(t, 1, evmClient.CallCallCount())
232+
_, calledTo, _, _ := evmClient.CallArgsForCall(0)
233+
assert.Equal(t, addrA, calledTo,
234+
"QueryTokens for the tms-b namespace silently reads through TMS A's TokenState clone")
235+
assert.NotEqual(t, addrB, calledTo, "it must not be tms-b's own configured TokenState")
236+
237+
// --- Observation 2: the submitter --------------------------------------------------------------
238+
//
239+
// There is exactly one Submitter for the whole Network, built once in Driver.New/newSubmitter
240+
// from the same single config. Broadcast (network.go:264) does not even take a TMS/namespace
241+
// argument, so there is structurally no way for it to route TMS B's transaction anywhere but
242+
// through that one submitter, targeting whatever TokenState it was built with.
243+
evmClient.PendingNonceAtReturns(3, nil)
244+
evmClient.EstimateGasReturns(100_000, nil)
245+
evmClient.SuggestGasFeesReturns(client.GasFees{
246+
MaxFeePerGas: big.NewInt(20_000_000_000),
247+
MaxPriorityFeePerGas: big.NewInt(1_000_000_000),
248+
}, nil)
249+
evmClient.SendRawTransactionReturns(client.Hash{}, nil)
250+
251+
env := &Envelope{
252+
Anchor: anchorHex(0xA1),
253+
Delta: testDelta(),
254+
Endorsements: [][]byte{make([]byte, 65)},
255+
}
256+
require.NoError(t, n.Broadcast(t.Context(), env)) // this stands in for TMS B's own broadcast
257+
258+
require.Equal(t, 1, evmClient.EstimateGasCallCount())
259+
_, estimateMsg := evmClient.EstimateGasArgsForCall(0)
260+
require.NotNil(t, estimateMsg.To)
261+
assert.Equal(t, addrA, *estimateMsg.To,
262+
"the shared submitter targets TMS A's TokenState for a transaction that has nothing to do with TMS A")
263+
assert.NotEqual(t, addrB, *estimateMsg.To)
264+
265+
// --- Observation 3: the EIP-712 domain ----------------------------------------------------------
266+
//
267+
// installEndorsement (driver.go:266-320) builds ONE endorsement.ServiceFactory carrying ONE
268+
// eip712.Domain{VerifyingContract: <config.TokenStateAddress()>} (driver.go:277-285), from the
269+
// exact same config object NewNetwork used. ServiceFactory.ForTMS (endorsement/esp.go:104-141)
270+
// only varies the resolved RequestValidator per TMS id; domain, tokenState, client, registry and
271+
// threshold are all single fields on the factory, reused unchanged for every TMS it ever builds a
272+
// Service for. Get the actual *endorsement.Service the driver built for TMS B and read its live
273+
// domain rather than re-deriving what it "should" be.
274+
svcB, err := n.endorsementForID(tmsB)
275+
require.NoError(t, err)
276+
domainB := extractDomain(t, svcB)
277+
assert.Equal(t, addrA, domainB.VerifyingContract,
278+
"TMS B's endorsement service signs and verifies EIP-712 digests against TMS A's TokenState address")
279+
assert.NotEqual(t, addrB, domainB.VerifyingContract)
280+
assert.Equal(t, configA.ChainIDBig().String(), domainB.ChainID.String())
281+
}
282+
283+
// repeat returns s repeated n times, a tiny local helper so the two test addresses above are
284+
// visibly-constructed, easy-to-eyeball hex strings instead of opaque literals.
285+
func repeat(s string, n int) string {
286+
out := make([]byte, 0, len(s)*n)
287+
for range n {
288+
out = append(out, s...)
289+
}
290+
291+
return string(out)
292+
}

0 commit comments

Comments
 (0)