|
| 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