diff --git a/docs/services/network-ethereum.md b/docs/services/network-ethereum.md index 3dc628a26b..8c1de5876e 100644 --- a/docs/services/network-ethereum.md +++ b/docs/services/network-ethereum.md @@ -310,16 +310,16 @@ sequenceDiagram E1->>E1: Validate token request E1->>E1: Compute state delta E1->>E1: Sign state delta - E1-->>Driver: Signature 1 + E1-->>Driver: State delta + signature 1 and Driver->>E2: Request endorsement E2->>E2: Validate token request E2->>E2: Compute state delta E2->>E2: Sign state delta - E2-->>Driver: Signature 2 + E2-->>Driver: State delta + signature 2 end - Driver->>Driver: Assemble state update + signatures + Driver->>Driver: Check the deltas agree, assemble with signatures Note over Driver,State: On-Chain Execution Phase Driver->>Node: eth_sendRawTransaction diff --git a/x/token/services/network/evm/driver.go b/x/token/services/network/evm/driver.go index fc9be6d39e..37307d0197 100644 --- a/x/token/services/network/evm/driver.go +++ b/x/token/services/network/evm/driver.go @@ -274,7 +274,6 @@ func (d *Driver) installEndorsement(n *Network, config *Config, evmClient client BlockTag: config.Finality.BlockTag, PublicParams: pp.NewChainProvider(evmClient, tokenState, config.Finality.BlockTag), ViewManager: d.viewManager, - TMS: d.resolveTMS, }) if err != nil { return err diff --git a/x/token/services/network/evm/endorsement/delta.go b/x/token/services/network/evm/endorsement/delta.go index f87741e198..f386053a23 100644 --- a/x/token/services/network/evm/endorsement/delta.go +++ b/x/token/services/network/evm/endorsement/delta.go @@ -18,11 +18,11 @@ import ( "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta" ) -// DeltaFactory turns a validated token request into the StateDelta both sides of the flow work with: -// the responder signs its EIP-712 digest, the initiator assembles the signatures over it and encodes -// it into the transaction. Sharing one construction path is how the §4.4 determinism guarantee (every -// endorser and the initiator produce byte-identical deltas) is met, by running the same code rather -// than trusting independent reimplementations to agree. +// DeltaFactory turns a validated token request into the StateDelta an endorser signs and returns. It +// belongs to the responder alone: the initiator neither validates nor translates, it takes the delta +// from the endorsers' replies. The §4.4 determinism guarantee (every endorser produces byte-identical +// deltas) is met by them all running this one construction path, rather than by trusting independent +// reimplementations to agree. // // Build validates the request against on-chain state (read through the getToken ledger at blockTag), // then translates the validated actions with the StateDelta translator, binding the public @@ -56,35 +56,8 @@ func NewDeltaFactory( } } -// ResolveValidator adapts a function that looks a validator up into a RequestValidator, so a delta -// factory resolves one per request instead of holding the one it was built with. -// -// The validator a TMS hands out is derived from that TMS's public parameters, and updating parameters -// evicts the management service so that the next caller builds a new one. A validator captured at -// construction therefore keeps checking actions against parameters that no longer describe the -// network, and so does a validator re-read from a management service captured at construction: both -// have to be resolved from the id, per request. Getting this wrong is not subtle in its effects - -// after an update that authorises a new issuer, that issuer's every request is rejected as -// unauthorised, by a node that has already logged the new parameters. -type ResolveValidator func() (RequestValidator, error) - -// UnmarshallAndVerifyWithMetadata resolves the current validator and delegates to it. -func (r ResolveValidator) UnmarshallAndVerifyWithMetadata( - ctx context.Context, - ledger token2.Ledger, - anchor token2.RequestAnchor, - raw []byte, -) ([]any, map[string][]byte, error) { - validator, err := r() - if err != nil { - return nil, nil, errors.Wrap(err, "failed to resolve the validator") - } - - return validator.UnmarshallAndVerifyWithMetadata(ctx, ledger, anchor, raw) -} - -// Build validates req against on-chain state and returns the StateDelta to sign or assemble. A -// validation failure is wrapped with ErrValidation so callers can classify it. +// Build validates req against on-chain state and returns the StateDelta to sign. A validation +// failure is wrapped with ErrValidation so callers can classify it. func (f *DeltaFactory) Build(ctx context.Context, req *EndorseRequest) (*statedelta.StateDelta, error) { ppRaw, ppVersion, err := f.pp.PublicParams(ctx) if err != nil { @@ -125,6 +98,3 @@ func (f *DeltaFactory) Build(ctx context.Context, req *EndorseRequest) (*statede return tr.StateDelta() } - -// compile-time check that DeltaFactory satisfies the DeltaBuilder the initiator depends on. -var _ DeltaBuilder = (*DeltaFactory)(nil) diff --git a/x/token/services/network/evm/endorsement/doc.go b/x/token/services/network/evm/endorsement/doc.go index f38bb00166..0c542fe317 100644 --- a/x/token/services/network/evm/endorsement/doc.go +++ b/x/token/services/network/evm/endorsement/doc.go @@ -16,17 +16,23 @@ SPDX-License-Identifier: Apache-2.0 // the request. // - Responder (responder.go) is the endorser side: receive → authorize (allowlist, the EVM analog // of the Fabric MSP/ACL check) → validate the request against on-chain state (ledger.go, getToken -// at a finalized block tag) → translate to a StateDelta → sign its EIP-712 digest. It recomputes -// the digest from the validated actions and never signs one handed to it (design §4.5). +// at a finalized block tag) → translate to a StateDelta → sign its EIP-712 digest → reply with +// both. It recomputes the digest from the validated actions and never signs one handed to it +// (design §4.5). // - Initiator (initiator.go) is the collector side: open a session to each registered endorser, // gather replies, and count a signature only after recovering it to a distinct registered endorser -// over the digest it computed itself, mirroring the contract's threshold and distinct-signer rules. -// - DeltaFactory (delta.go) is the single validate-and-translate path both sides build the delta -// through, so every endorser and the initiator produce byte-identical deltas (the §4.4 -// determinism guarantee), and Service (service.go) is the per-TMS entry point RequestApproval -// drives. +// over the digest of the delta that came back with it, mirroring the contract's threshold and +// distinct-signer rules. +// - DeltaFactory (delta.go) is the responder's validate-and-translate path, the one every endorser +// runs so they all produce byte-identical deltas (the §4.4 determinism guarantee), and Service +// (service.go) is the per-TMS entry point RequestApproval drives. // -// The request carries no precomputed digest (messages.go): endorsers recompute it. The end-to-end -// guarantee is pinned by the gate (gate_test.go + contracts/test/Endorsement2ofN.t.sol): a 2-of-3 -// quorum assembled over in-memory sessions verifies on the EndorsementVerifier on-chain. +// Both directions of the wire follow the same principle: the party that does the work is the party +// that decides what it means. The request carries no precomputed digest, so endorsers recompute +// rather than blind-sign; the response carries the delta, so the initiator relays rather than +// revalidates. That is the shape Fabric already has, where the request travels as transient data and +// the RWSet comes back inside the endorsers' proposal responses. +// +// The end-to-end guarantee is pinned by the gate (gate_test.go + contracts/test/Endorsement2ofN.t.sol): +// a 2-of-3 quorum assembled over in-memory sessions verifies on the EndorsementVerifier on-chain. package endorsement diff --git a/x/token/services/network/evm/endorsement/errors.go b/x/token/services/network/evm/endorsement/errors.go index 77c157c127..b2b2dccc06 100644 --- a/x/token/services/network/evm/endorsement/errors.go +++ b/x/token/services/network/evm/endorsement/errors.go @@ -39,4 +39,14 @@ var ( // contract counts distinct signers only, so the initiator must not assemble a quorum that would be // rejected on-chain. ErrDuplicateSigner = errors.New("duplicate endorser signature") + + // ErrDeltaMismatch is returned when an endorser's delta does not belong to the request it was + // asked about, or is not structurally well formed. The signature over it is not counted. + ErrDeltaMismatch = errors.New("state delta does not match the request") + + // ErrDivergentDeltas accompanies ErrInsufficientEndorsements when endorsers answered but signed + // different deltas for the same request. Endorsers that validated the same request must translate + // it identically, so this means the translation is not deterministic, not that endorsers were + // unavailable. + ErrDivergentDeltas = errors.New("endorsers disagree on the state delta") ) diff --git a/x/token/services/network/evm/endorsement/esp.go b/x/token/services/network/evm/endorsement/esp.go index 61dd73e87e..9659d7337c 100644 --- a/x/token/services/network/evm/endorsement/esp.go +++ b/x/token/services/network/evm/endorsement/esp.go @@ -18,15 +18,13 @@ import ( // ServiceFactory builds the endorsement service for a TMS, and caches it. // -// The service is per TMS rather than per network because validating a token request needs that TMS's -// validator, which knows its token driver and public parameters. Everything else it needs (the -// endorser set, the threshold, the chain, this node's signing key) is network-wide and is resolved -// once, here. +// The service is keyed by TMS because that is the unit RequestApproval works in, but it no longer +// holds anything derived from a TMS: the initiator collects signatures and takes the delta from the +// endorsers, so nothing on that side needs a validator. Validation lives entirely in the responder, +// which resolves its TMS when a request arrives. // -// The factory takes a TMS id and resolves the management service itself, per request. Holding the -// service would be wrong: updating public parameters evicts the cached management service and the next -// caller gets a rebuilt one, so a captured pointer keeps serving the parameters that were current when -// it was captured, and no amount of re-asking it for a validator changes that. +// What the factory does hold is network-wide and resolved once: the endorser set, the threshold, the +// chain, the EIP-712 domain, and the parameters a delta is bound to. type ServiceFactory struct { registry *Registry threshold int @@ -36,7 +34,6 @@ type ServiceFactory struct { blockTag string publicParams PublicParamsProvider viewManager ViewManager - resolveTMS TMSResolver mu sync.Mutex services map[string]*Service @@ -60,8 +57,6 @@ type FactoryConfig struct { PublicParams PublicParamsProvider // ViewManager runs the initiator. ViewManager ViewManager - // TMS resolves a management service from its id, on every request rather than once. - TMS TMSResolver } // NewServiceFactory returns a factory for the given network. @@ -78,9 +73,6 @@ func NewServiceFactory(cfg FactoryConfig) (*ServiceFactory, error) { if cfg.PublicParams == nil { return nil, errors.New("endorsement factory: nil public parameters provider") } - if cfg.TMS == nil { - return nil, errors.New("endorsement factory: nil tms resolver") - } if cfg.Threshold < 1 || cfg.Threshold > cfg.Registry.Len() { return nil, errors.Errorf("endorsement factory: threshold %d out of range [1,%d]", cfg.Threshold, cfg.Registry.Len()) @@ -95,7 +87,6 @@ func NewServiceFactory(cfg FactoryConfig) (*ServiceFactory, error) { blockTag: cfg.BlockTag, publicParams: cfg.PublicParams, viewManager: cfg.ViewManager, - resolveTMS: cfg.TMS, services: map[string]*Service{}, }, nil } @@ -113,25 +104,7 @@ func (f *ServiceFactory) ForTMS(tmsID token2.TMSID) (*Service, error) { return service, nil } - // Both the management service and the validator are resolved per request. The service cached here - // lives for the life of the node, but an endorsed setup delta replaces the management service - // underneath it, and only a fresh one knows the new public parameters. - resolve := ResolveValidator(func() (RequestValidator, error) { - tms, err := f.resolveTMS(tmsID) - if err != nil { - return nil, errors.Wrapf(err, "failed to resolve tms [%s]", tmsID) - } - - return tms.Validator() - }) - - service, err := NewService( - f.registry, - f.threshold, - NewDeltaFactory(resolve, f.publicParams, f.client, f.tokenState, f.blockTag), - f.domain, - f.viewManager, - ) + service, err := NewService(f.registry, f.threshold, f.domain, f.viewManager) if err != nil { return nil, errors.Wrapf(err, "endorsement factory: failed to build the service for [%s]", tmsID) } diff --git a/x/token/services/network/evm/endorsement/esp_test.go b/x/token/services/network/evm/endorsement/esp_test.go index 29c2f0e98e..64a1959844 100644 --- a/x/token/services/network/evm/endorsement/esp_test.go +++ b/x/token/services/network/evm/endorsement/esp_test.go @@ -32,9 +32,6 @@ func testFactoryConfig(t *testing.T) FactoryConfig { TokenState: addr(0xAA), PublicParams: &fakePP{raw: []byte("pp"), version: 1}, ViewManager: &stubViewManager{}, - TMS: func(token2.TMSID) (*token2.ManagementService, error) { - return nil, assert.AnError - }, } } @@ -51,7 +48,6 @@ func TestNewServiceFactoryValidates(t *testing.T) { "no client": func(c *FactoryConfig) { c.Client = nil }, "no view manager": func(c *FactoryConfig) { c.ViewManager = nil }, "no public parameters": func(c *FactoryConfig) { c.PublicParams = nil }, - "no tms resolver": func(c *FactoryConfig) { c.TMS = nil }, "zero threshold": func(c *FactoryConfig) { c.Threshold = 0 }, "threshold too high": func(c *FactoryConfig) { c.Threshold = 3 }, } @@ -65,8 +61,8 @@ func TestNewServiceFactoryValidates(t *testing.T) { } } -// TestForTMSRejectsAnEmptyID checks the factory does not build a service it could never resolve a TMS -// for, since the TMS is where the validator comes from. +// TestForTMSRejectsAnEmptyID checks the factory does not build a service for an id it could not route +// a request under. func TestForTMSRejectsAnEmptyID(t *testing.T) { f, err := NewServiceFactory(testFactoryConfig(t)) require.NoError(t, err) @@ -75,68 +71,53 @@ func TestForTMSRejectsAnEmptyID(t *testing.T) { require.Error(t, err) } -// TestForTMSResolvesTheTMSPerRequest pins the reason the initiator holds a TMS id and not a TMS. +// TestForTMSCachesPerTMS checks the service is built once and reused. It holds nothing derived from a +// TMS any more (the initiator collects signatures and takes the delta from the endorsers), so the +// cache is only about not rebuilding the same collaborators on every approval. +func TestForTMSCachesPerTMS(t *testing.T) { + f, err := NewServiceFactory(testFactoryConfig(t)) + require.NoError(t, err) + + tmsID := token2.TMSID{Network: "evm", Namespace: "token"} + service, err := f.ForTMS(tmsID) + require.NoError(t, err) + again, err := f.ForTMS(tmsID) + require.NoError(t, err) + assert.Same(t, service, again) +} + +// TestResponderResolvesTheTMSPerRequest pins why the responder holds a TMS id and not a TMS. // // Updating public parameters evicts the cached management service, so the next caller gets a rebuilt // one and whoever kept the old pointer keeps its old parameters. Asking that stale service for a -// validator again does not help, which is what made this worth a test: the earlier fix resolved the +// validator again does not help, which is what made this worth a test: an earlier fix resolved the // validator per request but from a captured service, and the symptom did not move. After an update // that authorises a new issuer, that issuer's requests were still rejected as unauthorised on a node // that had already logged the new parameters. -func TestForTMSResolvesTheTMSPerRequest(t *testing.T) { - cfg := testFactoryConfig(t) - calls := 0 - cfg.TMS = func(token2.TMSID) (*token2.ManagementService, error) { - calls++ - - return nil, assert.AnError - } - f, err := NewServiceFactory(cfg) - require.NoError(t, err) - - service, err := f.ForTMS(token2.TMSID{Network: "evm", Namespace: "token"}) +// +// Endorsement is now the only place a validator is used at all, so this is the one path where it +// matters. +func TestResponderResolvesTheTMSPerRequest(t *testing.T) { + f, err := NewServiceFactory(testFactoryConfig(t)) require.NoError(t, err) - assert.Equal(t, 0, calls, "building the service must not resolve a TMS") - // The same cached service, asked twice: it must go back to the resolver both times. - again, err := f.ForTMS(token2.TMSID{Network: "evm", Namespace: "token"}) + auth, err := NewAuthorizer([]view.Identity{view.Identity(testCaller)}) require.NoError(t, err) - assert.Same(t, service, again, "the service itself is still cached per TMS") - - for range 2 { - _, err := service.factory.Build(t.Context(), - &EndorseRequest{Anchor: "anchor", TokenRequest: []byte("tr")}) - require.Error(t, err) - } - assert.Equal(t, 2, calls, "the TMS must be resolved per request, not captured once") -} -// TestResolveValidatorIsCalledPerBuild is the same invariant one layer down, on the adapter itself. -func TestResolveValidatorIsCalledPerBuild(t *testing.T) { calls := 0 - resolve := ResolveValidator(func() (RequestValidator, error) { + responder, err := f.NewResponder(auth, newSigner(t, 1), func(token2.TMSID) (*token2.ManagementService, error) { calls++ - return &fakeValidator{err: assert.AnError}, nil + return nil, assert.AnError }) - factory := NewDeltaFactory(resolve, &fakePP{raw: []byte("pp"), version: 1}, nil, addr(0xAA), "") + require.NoError(t, err) + assert.Equal(t, 0, calls, "building the responder must not resolve a TMS") for range 2 { - _, err := factory.Build(t.Context(), &EndorseRequest{Anchor: "anchor", TokenRequest: []byte("tr")}) - require.Error(t, err) + resp := responder.Handle(t.Context(), view.Identity(testCaller), validRequest()) + require.Error(t, resp.Error(), "an unresolvable TMS is refused, not signed for") } - assert.Equal(t, 2, calls, "the validator must be resolved per request, not captured once") -} - -// TestResolveValidatorReportsResolutionFailure checks a validator that cannot be resolved surfaces as -// an error rather than a nil dereference inside validation. -func TestResolveValidatorReportsResolutionFailure(t *testing.T) { - resolve := ResolveValidator(func() (RequestValidator, error) { return nil, assert.AnError }) - factory := NewDeltaFactory(resolve, &fakePP{raw: []byte("pp"), version: 1}, nil, addr(0xAA), "") - - _, err := factory.Build(t.Context(), &EndorseRequest{Anchor: "anchor", TokenRequest: []byte("tr")}) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to resolve the validator") + assert.Equal(t, 2, calls, "the TMS must be resolved per request, not captured once") } // TestNewResponderForNeedsAKey checks a node that does not endorse cannot be turned into a responder: diff --git a/x/token/services/network/evm/endorsement/fakes_test.go b/x/token/services/network/evm/endorsement/fakes_test.go index f3dda25e0c..7e94cc8c04 100644 --- a/x/token/services/network/evm/endorsement/fakes_test.go +++ b/x/token/services/network/evm/endorsement/fakes_test.go @@ -69,7 +69,9 @@ func (s *pipeSession) Close() {} // own session via Session. Everything the endorsement views do not touch panics, so an unexpected // dependency surfaces loudly rather than silently. type fakeContext struct { - ctx context.Context + // view.Context is an interface whose Context() method returns one, so an implementation of it has + // to hold a context. + ctx context.Context //nolint:containedctx me view.Identity sessions map[string]view.Session // keyed by party UniqueID, for GetSession (initiator side) own view.Session // for Session() (responder side) diff --git a/x/token/services/network/evm/endorsement/gate_test.go b/x/token/services/network/evm/endorsement/gate_test.go index 7ae95d9b55..117a8847df 100644 --- a/x/token/services/network/evm/endorsement/gate_test.go +++ b/x/token/services/network/evm/endorsement/gate_test.go @@ -136,7 +136,7 @@ func (c *gateContext) GetSession(_ view.View, party view.Identity, _ ...view.Vie // and asserts a 2-of-3 quorum is assembled whose signatures recover to distinct registered endorsers. func TestGateAssembleQuorumOverSessions(t *testing.T) { reg, responders := gateEndorsers(t, 3) - initiator := NewInitiator(reg, gateThreshold, gateFactory(), gateDomain(t), gateRequest()) + initiator := NewInitiator(reg, gateThreshold, gateDomain(t), gateRequest()) ctx := &gateContext{ fakeContext: fakeContext{ctx: context.Background(), me: view.Identity(gateInitiator)}, @@ -167,7 +167,7 @@ func TestGateAssembleQuorumOverSessions(t *testing.T) { // on-chain check exercises exactly what the initiator produces. func TestGateFixtureMatchesAssembly(t *testing.T) { reg, responders := gateEndorsers(t, 3) - initiator := NewInitiator(reg, gateThreshold, gateFactory(), gateDomain(t), gateRequest()) + initiator := NewInitiator(reg, gateThreshold, gateDomain(t), gateRequest()) ctx := &gateContext{ fakeContext: fakeContext{ctx: context.Background(), me: view.Identity(gateInitiator)}, responders: responders, diff --git a/x/token/services/network/evm/endorsement/initiator.go b/x/token/services/network/evm/endorsement/initiator.go index 20d4bcd1e2..40e54b70ef 100644 --- a/x/token/services/network/evm/endorsement/initiator.go +++ b/x/token/services/network/evm/endorsement/initiator.go @@ -15,65 +15,67 @@ import ( session2 "github.com/LFDT-Panurus/panurus/token/services/utils/json/session" "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/eip712" + "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/keys" "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta" ) // responseTimeout bounds how long the initiator waits for one endorser's reply. const responseTimeout = 30 * time.Second -// DeltaBuilder produces the StateDelta a request translates to. The initiator builds the delta -// itself (determinism guarantees it equals what the endorsers signed), both to know the digest it -// must verify each signature against and to carry the delta in the assembled result. *DeltaFactory -// satisfies it. -type DeltaBuilder interface { - // Build validates and translates req into its StateDelta. - Build(ctx context.Context, req *EndorseRequest) (*statedelta.StateDelta, error) -} - // Result is what a completed endorsement yields: the delta to apply and the collected quorum of // signatures over its EIP-712 digest. The driver's RequestApproval wraps it into the network -// envelope; Broadcast (Week 5) ABI-encodes applyStateDelta(delta, endorsements). +// envelope; Broadcast ABI-encodes applyStateDelta(delta, endorsements). type Result struct { // Anchor is the token-request anchor the endorsement is for. Anchor string - // Delta is the StateDelta every endorser signed and the transaction will apply. + // Delta is the StateDelta the quorum agreed on and the transaction will apply. It comes from the + // endorsers, not from the initiator. Delta *statedelta.StateDelta // Endorsements are the collected 65-byte {r,s,v} signatures, one per distinct endorser, in the - // order they were collected. Each has been recovered to a registered endorser and de-duplicated, - // so the set satisfies the contract's threshold and distinct-signer rules. + // order they were collected. Each has been recovered to a registered endorser and de-duplicated + // against the digest of Delta, so the set satisfies the contract's threshold and distinct-signer + // rules. Endorsements [][]byte } -// Initiator collects a threshold of endorser signatures over a request's StateDelta and assembles -// them into a Result. It is the EVM analog of the Fabric RequestApprovalView (design §6.3): it opens -// a session to each registered endorser, sends the request, and gathers the replies. +// Initiator collects a threshold of endorser signatures over one request and assembles them into a +// Result. It is the EVM analog of the Fabric RequestApprovalView (design §6.3): it opens a session to +// each registered endorser, sends the request, and gathers the replies. +// +// It does no validation and builds no StateDelta of its own. Validating a token request and +// translating it is the endorsers' job, and it is what this whole flow exists to delegate; each +// endorser returns the delta it built alongside its signature, exactly as a Fabric endorser returns +// the RWSet inside its proposal response. Rebuilding the delta locally would mean an initiator +// repeating every endorser's work, reading the chain itself, and failing the transaction whenever its +// own view of public parameters or ledger state differed from theirs. None of that buys a security +// property: the contract's rule is that a threshold of registered endorsers signed the digest, and a +// quorum that wanted to submit something else would not need the initiator at all. // -// It does not trust an endorser's self-reported address: it recovers the signer from each signature -// against the digest it computed locally, and counts a signature only if it recovers to a registered -// endorser not already counted. This mirrors the contract's on-chain rules (recover, authorize, -// distinct) so the initiator never assembles a quorum the contract would reject. +// What it does check is cheap and needs no validator: that a returned delta is bound to the anchor it +// asked about and is structurally well formed, that each signature recovers to a registered endorser +// over that delta's own digest, and that the endorsers counted are distinct. This mirrors the +// contract's rules (recover, authorize, distinct), so the initiator never assembles a quorum the +// contract would reject. type Initiator struct { registry *Registry threshold int - builder DeltaBuilder domain eip712.Domain request *EndorseRequest } // NewInitiator returns an Initiator for one request. threshold is the number of distinct endorser // signatures the quorum requires. -func NewInitiator(registry *Registry, threshold int, builder DeltaBuilder, domain eip712.Domain, request *EndorseRequest) *Initiator { +func NewInitiator(registry *Registry, threshold int, domain eip712.Domain, request *EndorseRequest) *Initiator { return &Initiator{ registry: registry, threshold: threshold, - builder: builder, domain: domain, request: request, } } -// Call implements the FSC initiator view: build the delta, then request an endorsement from each -// registered endorser over its own session and assemble the quorum. It returns the *Result. +// Call implements the FSC initiator view: request an endorsement from each registered endorser over +// its own session and assemble the quorum. It returns the *Result. func (i *Initiator) Call(context view.Context) (any, error) { return i.Collect(context.Context(), func(party view.Identity) (*EndorseResponse, error) { return i.requestFrom(context, party) @@ -97,24 +99,36 @@ func (i *Initiator) requestFrom(context view.Context, party view.Identity) (*End return &resp, nil } -// Collect drives the assembly independently of the session transport: for each registered endorser -// it calls endorse, verifies the reply, and accumulates distinct valid signatures until the -// threshold is met. Separating it from Call keeps the quorum logic testable without an FSC runtime. +// agreement accumulates the signatures collected over one delta. Endorsers that validated the same +// request must translate it into byte-identical deltas (the §4.4 determinism guarantee), so a healthy +// network produces exactly one of these. Keying them by digest is what makes a divergent endorser +// merely ignored rather than able to break every transaction it takes part in. +type agreement struct { + delta *statedelta.StateDelta + signatures [][]byte + signers map[string]struct{} +} + +// Collect drives the assembly independently of the session transport: for each registered endorser it +// calls endorse, checks the reply, and accumulates distinct valid signatures per delta until one delta +// reaches the threshold. Separating it from Call keeps the quorum logic testable without an FSC +// runtime. // -// A single endorser's failure (declined, unreachable, malformed or unauthorized signature) is not -// fatal: the initiator moves on and still succeeds if enough others sign. It fails only when fewer -// than threshold distinct endorsers produced a verifiable signature. +// A single endorser's failure (declined, unreachable, malformed or unauthorized signature, or a delta +// that does not belong to this request) is not fatal: the initiator moves on and still succeeds if +// enough others agree. It fails only when no single delta collected threshold distinct signatures. func (i *Initiator) Collect(ctx context.Context, endorse func(view.Identity) (*EndorseResponse, error)) (*Result, error) { - delta, err := i.builder.Build(ctx, i.request) + anchor, err := keys.AnchorFromTxID(i.request.Anchor) if err != nil { - return nil, errors.Wrap(err, "failed to build delta") + return nil, errors.Wrapf(err, "invalid anchor [%s]", i.request.Anchor) } - digest := eip712.Digest(i.domain, delta) - - signatures := make([][]byte, 0, i.threshold) - seen := make(map[string]struct{}, i.threshold) + agreed := make(map[[32]byte]*agreement, 1) for _, party := range i.registry.Identities() { + if err := ctx.Err(); err != nil { + return nil, errors.Wrap(err, "endorsement collection interrupted") + } + resp, err := endorse(party) if err != nil { logger.Debugf("endorser [%s] did not respond: %v", party, err) @@ -126,31 +140,65 @@ func (i *Initiator) Collect(ctx context.Context, endorse func(view.Identity) (*E continue } + if err := i.bind(anchor, resp.Delta); err != nil { + logger.Debugf("discarding delta from [%s]: %v", party, err) + + continue + } + digest := eip712.Digest(i.domain, resp.Delta) signer, err := i.verify(digest, resp.Signature) if err != nil { logger.Debugf("discarding signature from [%s]: %v", party, err) continue } - if _, dup := seen[signer]; dup { + + quorum, known := agreed[digest] + if !known { + quorum = &agreement{delta: resp.Delta, signers: make(map[string]struct{}, i.threshold)} + agreed[digest] = quorum + if len(agreed) > 1 { + // Two endorsers validated the same request and translated it differently. That is a + // determinism bug in the translator rather than a fault of either endorser, and it stays + // invisible if it is only ever reported as a missing quorum. + logger.Warnf("endorsers disagree on the delta for [%s]: %d distinct deltas so far", + i.request.Anchor, len(agreed)) + } + } + if _, dup := quorum.signers[signer]; dup { logger.Debugf("discarding duplicate signature recovered to [%s]", signer) continue } - seen[signer] = struct{}{} - signatures = append(signatures, resp.Signature) + quorum.signers[signer] = struct{}{} + quorum.signatures = append(quorum.signatures, resp.Signature) - if len(signatures) >= i.threshold { - break + if len(quorum.signatures) >= i.threshold { + return &Result{Anchor: i.request.Anchor, Delta: quorum.delta, Endorsements: quorum.signatures}, nil } } - if len(signatures) < i.threshold { - return nil, errors.Wrapf(ErrInsufficientEndorsements, "collected %d of %d required", len(signatures), i.threshold) + return nil, noQuorum(agreed, i.threshold) +} + +// bind checks that a returned delta belongs to the request this initiator sent, using only what can be +// computed without a validator: the anchor is derived from the request, and the delta's own structural +// invariants must hold before it is encoded into a transaction. +// +// It deliberately does not check TokenRequestHash. That hash covers the validator's TokenRequestToSign +// attribute rather than the marshalled request the initiator holds, so reproducing it would mean +// running the validation this flow delegates. The endorsers bind it, and the contract stores what they +// signed. +func (i *Initiator) bind(anchor [keys.AnchorLength]byte, delta *statedelta.StateDelta) error { + if delta.Anchor != anchor { + return errors.Wrapf(ErrDeltaMismatch, "delta anchor [%x] is not the request anchor [%x]", delta.Anchor, anchor) + } + if err := delta.Validate(); err != nil { + return errors.Wrapf(ErrDeltaMismatch, "malformed delta: %v", err) } - return &Result{Anchor: i.request.Anchor, Delta: delta, Endorsements: signatures}, nil + return nil } // verify recovers the signer from sig over digest and confirms it is a registered endorser. It @@ -167,3 +215,22 @@ func (i *Initiator) verify(digest [32]byte, sig []byte) (string, error) { return address.Hex(), nil } + +// noQuorum reports why the collection ended without one. It separates too few endorsements from +// endorsers that answered but disagreed on the delta, because the second is a determinism failure in +// the translator and needs a different fix than an endorser that was simply unavailable. +func noQuorum(agreed map[[32]byte]*agreement, threshold int) error { + best := 0 + for _, quorum := range agreed { + if len(quorum.signatures) > best { + best = len(quorum.signatures) + } + } + if len(agreed) > 1 { + return errors.Wrapf(errors.Join(ErrInsufficientEndorsements, ErrDivergentDeltas), + "endorsers signed %d distinct deltas, the largest agreement had %d of %d required", + len(agreed), best, threshold) + } + + return errors.Wrapf(ErrInsufficientEndorsements, "collected %d of %d required", best, threshold) +} diff --git a/x/token/services/network/evm/endorsement/initiator_test.go b/x/token/services/network/evm/endorsement/initiator_test.go index b6b4267d7e..e8a112af27 100644 --- a/x/token/services/network/evm/endorsement/initiator_test.go +++ b/x/token/services/network/evm/endorsement/initiator_test.go @@ -15,35 +15,48 @@ import ( "github.com/stretchr/testify/require" "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/eip712" + "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/keys" "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta" ) -// fakeDeltaBuilder returns a fixed delta, so initiator tests isolate the assembly logic from -// validation/translation (exercised in the responder and delta tests). -type fakeDeltaBuilder struct { - delta *statedelta.StateDelta - err error -} +// requestAnchor is the request's anchor in the form a delta carries it. A delta the initiator accepts +// must be bound to exactly this. +func requestAnchor(t *testing.T) [keys.AnchorLength]byte { + t.Helper() + a, err := keys.AnchorFromTxID(validRequest().Anchor) + require.NoError(t, err) -func (f *fakeDeltaBuilder) Build(context.Context, *EndorseRequest) (*statedelta.StateDelta, error) { - return f.delta, f.err + return a } -// fixedDelta is a minimal, well-formed delta for digest computation. Its exact contents do not -// matter; determinism does, so the initiator and the signers compute the same digest. -func fixedDelta() *statedelta.StateDelta { +// endorsedDelta is a minimal, well-formed delta bound to the request's anchor, standing in for what an +// endorser returns after validating and translating. Its exact contents do not matter; that every +// endorser returns the same bytes does, since that is what makes their signatures assemble. +func endorsedDelta(t *testing.T) *statedelta.StateDelta { + t.Helper() var trh, pph [32]byte trh[0] = 0x11 pph[0] = 0x22 return &statedelta.StateDelta{ - Anchor: [32]byte{0xC1}, + Anchor: requestAnchor(t), TokenRequestHash: trh, PublicParamsHash: pph, PublicParamsVersion: 1, } } +// divergentDelta is a second well-formed delta for the same request: what an endorser running a +// different translation would return. It is legitimate on its face, so only disagreement with the +// others distinguishes it. +func divergentDelta(t *testing.T) *statedelta.StateDelta { + t.Helper() + d := endorsedDelta(t) + d.PublicParamsVersion = 2 + + return d +} + // endorserSet builds a registry of n endorsers keyed to the well-known test private keys 1..n, and // returns their signers so a test can produce real signatures that recover to the registered // addresses. @@ -63,13 +76,22 @@ func endorserSet(t *testing.T, n int) (*Registry, []*eip712.Signer) { } func newTestInitiator(reg *Registry, threshold int) *Initiator { - return NewInitiator(reg, threshold, &fakeDeltaBuilder{delta: fixedDelta()}, testDomain(), validRequest()) + return NewInitiator(reg, threshold, testDomain(), validRequest()) +} + +// endorsedBy builds the response an honest endorser sends: the delta it built, and its signature over +// that delta's digest. +func endorsedBy(t *testing.T, s *eip712.Signer, delta *statedelta.StateDelta) *EndorseResponse { + t.Helper() + sig, err := s.Sign(eip712.Digest(testDomain(), delta)) + require.NoError(t, err) + + return &EndorseResponse{Delta: delta, Signature: sig, EndorserAddress: s.Address().Hex()} } -// signWith returns an endorse closure that answers, for the party whose identity matches a signer's -// address, a real signature over the initiator's digest. Parties not in the map return an error, so -// a test can model unreachable or non-responding endorsers. -func signWith(t *testing.T, signers map[string]*eip712.Signer, digest [32]byte) func(view.Identity) (*EndorseResponse, error) { +// answerWith returns an endorse closure where every listed party replies with delta, honestly signed. +// Parties not in the map return an error, so a test can model unreachable endorsers. +func answerWith(t *testing.T, signers map[string]*eip712.Signer, delta *statedelta.StateDelta) func(view.Identity) (*EndorseResponse, error) { t.Helper() return func(party view.Identity) (*EndorseResponse, error) { @@ -77,33 +99,34 @@ func signWith(t *testing.T, signers map[string]*eip712.Signer, digest [32]byte) if !ok { return nil, assert.AnError } - sig, err := s.Sign(digest) - require.NoError(t, err) - return &EndorseResponse{Signature: sig, EndorserAddress: s.Address().Hex()}, nil + return endorsedBy(t, s, delta), nil } } -func digestOf(delta *statedelta.StateDelta) [32]byte { return eip712.Digest(testDomain(), delta) } +// byAddress indexes signers the way the registry addresses them. +func byAddress(signers []*eip712.Signer) map[string]*eip712.Signer { + m := make(map[string]*eip712.Signer, len(signers)) + for _, s := range signers { + m[s.Address().Hex()] = s + } + + return m +} func TestInitiatorAssemblesQuorum(t *testing.T) { reg, signers := endorserSet(t, 3) init := newTestInitiator(reg, 2) - digest := digestOf(fixedDelta()) + delta := endorsedDelta(t) - // all three endorsers sign; the initiator stops at the threshold of 2 - answer := map[string]*eip712.Signer{} - for _, s := range signers { - answer[s.Address().Hex()] = s - } - - result, err := init.Collect(context.Background(), signWith(t, answer, digest)) + result, err := init.Collect(context.Background(), answerWith(t, byAddress(signers), delta)) require.NoError(t, err) require.Len(t, result.Endorsements, 2, "collection stops once the threshold is met") assert.Equal(t, validRequest().Anchor, result.Anchor) require.NotNil(t, result.Delta) - // every collected signature recovers to a distinct registered endorser + // every collected signature recovers to a distinct registered endorser over the delta's digest + digest := eip712.Digest(testDomain(), result.Delta) seen := map[string]struct{}{} for _, sig := range result.Endorsements { addr, err := eip712.RecoverAddress(digest, sig) @@ -115,31 +138,46 @@ func TestInitiatorAssemblesQuorum(t *testing.T) { } } +// TestInitiatorTakesTheDeltaFromTheEndorsers is the property this flow turns on: the initiator does +// not build a delta, it carries through the one the endorsers signed. The delta here holds a +// token-request hash the initiator has no way to derive (it covers the validator's +// TokenRequestToSign attribute, not the marshalled request), so a result carrying it can only have +// come from the responses. +func TestInitiatorTakesTheDeltaFromTheEndorsers(t *testing.T) { + reg, signers := endorserSet(t, 2) + init := newTestInitiator(reg, 2) + + delta := endorsedDelta(t) + delta.TokenRequestHash = [32]byte{0xDE, 0xAD, 0xBE, 0xEF} + delta.PublicParamsVersion = 42 + + result, err := init.Collect(context.Background(), answerWith(t, byAddress(signers), delta)) + require.NoError(t, err) + assert.Equal(t, delta, result.Delta, "the assembled delta is the endorsers', unchanged") +} + func TestInitiatorFailsBelowThreshold(t *testing.T) { reg, signers := endorserSet(t, 3) init := newTestInitiator(reg, 2) - digest := digestOf(fixedDelta()) // only one endorser answers answer := map[string]*eip712.Signer{signers[0].Address().Hex(): signers[0]} - _, err := init.Collect(context.Background(), signWith(t, answer, digest)) + _, err := init.Collect(context.Background(), answerWith(t, answer, endorsedDelta(t))) require.Error(t, err) - assert.ErrorIs(t, err, ErrInsufficientEndorsements) + require.ErrorIs(t, err, ErrInsufficientEndorsements) + assert.NotErrorIs(t, err, ErrDivergentDeltas, "one endorser cannot disagree with itself") } func TestInitiatorIgnoresDuplicateSigner(t *testing.T) { reg, signers := endorserSet(t, 3) init := newTestInitiator(reg, 2) - digest := digestOf(fixedDelta()) + delta := endorsedDelta(t) // endorser 0 answers for everyone: the same key recovered under three identities must count once, // so the quorum of 2 is never reached (the distinct-signer rule the contract also enforces). sameKey := func(view.Identity) (*EndorseResponse, error) { - sig, err := signers[0].Sign(digest) - require.NoError(t, err) - - return &EndorseResponse{Signature: sig, EndorserAddress: signers[0].Address().Hex()}, nil + return endorsedBy(t, signers[0], delta), nil } _, err := init.Collect(context.Background(), sameKey) @@ -150,20 +188,17 @@ func TestInitiatorIgnoresDuplicateSigner(t *testing.T) { func TestInitiatorDiscardsUnknownSigner(t *testing.T) { reg, signers := endorserSet(t, 2) init := newTestInitiator(reg, 2) - digest := digestOf(fixedDelta()) + delta := endorsedDelta(t) // a stranger (key 9, not registered) answers alongside one real endorser: the stranger's // signature recovers to an unregistered address and must not count toward the quorum. stranger := newSigner(t, 9) answer := func(party view.Identity) (*EndorseResponse, error) { if string(party) == signers[0].Address().Hex() { - sig, _ := signers[0].Sign(digest) - - return &EndorseResponse{Signature: sig}, nil + return endorsedBy(t, signers[0], delta), nil } - sig, _ := stranger.Sign(digest) - return &EndorseResponse{Signature: sig}, nil + return endorsedBy(t, stranger, delta), nil } _, err := init.Collect(context.Background(), answer) @@ -171,29 +206,156 @@ func TestInitiatorDiscardsUnknownSigner(t *testing.T) { assert.ErrorIs(t, err, ErrInsufficientEndorsements) } -func TestInitiatorDiscardsSignatureOverWrongDigest(t *testing.T) { +// TestInitiatorDiscardsSignatureOverAnotherDelta covers an endorser whose signature does not cover the +// delta it sent: recovery over the delta's own digest yields an unrelated address, so it is discarded +// exactly like a stranger's. +func TestInitiatorDiscardsSignatureOverAnotherDelta(t *testing.T) { reg, signers := endorserSet(t, 2) init := newTestInitiator(reg, 2) - // both endorsers sign a DIFFERENT digest than the initiator computes: every signature recovers to - // an unrelated address and is discarded, so no quorum forms. - wrong := digestOf(&statedelta.StateDelta{Anchor: [32]byte{0x99}, TokenRequestHash: [32]byte{0x1}, PublicParamsHash: [32]byte{0x2}, PublicParamsVersion: 1}) - answer := map[string]*eip712.Signer{} - for _, s := range signers { - answer[s.Address().Hex()] = s + answer := func(party view.Identity) (*EndorseResponse, error) { + s := byAddress(signers)[string(party)] + resp := endorsedBy(t, s, divergentDelta(t)) + resp.Delta = endorsedDelta(t) // the signature no longer covers the delta that travels with it + + return resp, nil + } + + _, err := init.Collect(context.Background(), answer) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInsufficientEndorsements) +} + +// TestInitiatorRejectsDeltaForAnotherAnchor is the binding check: an endorser could return a +// perfectly well-formed, honestly signed delta for a different request. The initiator has no +// validator to catch that with, but it does know the anchor it asked about. +func TestInitiatorRejectsDeltaForAnotherAnchor(t *testing.T) { + reg, signers := endorserSet(t, 2) + init := newTestInitiator(reg, 2) + + other, err := keys.AnchorFromTxID(anchorHex(0xEE)) + require.NoError(t, err) + elsewhere := endorsedDelta(t) + elsewhere.Anchor = other + + _, err = init.Collect(context.Background(), answerWith(t, byAddress(signers), elsewhere)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInsufficientEndorsements) +} + +// TestInitiatorRejectsMalformedDelta checks the initiator will not encode a delta that breaks the +// StateDelta invariants into a transaction, even when the signature over it is genuine. +func TestInitiatorRejectsMalformedDelta(t *testing.T) { + reg, signers := endorserSet(t, 2) + init := newTestInitiator(reg, 2) + + malformed := endorsedDelta(t) + malformed.MetadataKeys = [][32]byte{{0x02}, {0x01}} // not strictly ascending + malformed.MetadataVals = [][]byte{{0xAA}, {0xBB}} + + _, err := init.Collect(context.Background(), answerWith(t, byAddress(signers), malformed)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInsufficientEndorsements) +} + +// TestInitiatorRejectsResponseWithoutADelta covers an endorser that signs but sends nothing to verify +// the signature against. +func TestInitiatorRejectsResponseWithoutADelta(t *testing.T) { + reg, signers := endorserSet(t, 2) + init := newTestInitiator(reg, 2) + + answer := func(party view.Identity) (*EndorseResponse, error) { + resp := endorsedBy(t, byAddress(signers)[string(party)], endorsedDelta(t)) + resp.Delta = nil + + return resp, nil } - _, err := init.Collect(context.Background(), signWith(t, answer, wrong)) + _, err := init.Collect(context.Background(), answer) require.Error(t, err) assert.ErrorIs(t, err, ErrInsufficientEndorsements) } -func TestInitiatorPropagatesBuilderFailure(t *testing.T) { +// TestInitiatorIgnoresADivergentEndorser is the fault-tolerance property that keying by delta buys. +// One endorser out of three translates the request differently; the other two agree, so the quorum +// still forms over what they signed, and the odd one out is simply not counted. +func TestInitiatorIgnoresADivergentEndorser(t *testing.T) { + reg, signers := endorserSet(t, 3) + init := newTestInitiator(reg, 2) + + agreed, odd := endorsedDelta(t), divergentDelta(t) + first := signers[0].Address().Hex() + answer := func(party view.Identity) (*EndorseResponse, error) { + s := byAddress(signers)[string(party)] + if string(party) == first { + return endorsedBy(t, s, odd), nil + } + + return endorsedBy(t, s, agreed), nil + } + + result, err := init.Collect(context.Background(), answer) + require.NoError(t, err) + assert.Equal(t, agreed, result.Delta, "the quorum forms over the delta the majority signed") + require.Len(t, result.Endorsements, 2) +} + +// TestInitiatorReportsDivergence checks that when endorsers answer but no single delta reaches the +// threshold, the failure says so. Reported as a plain shortfall it would look like unavailable +// endorsers, and the determinism bug behind it would stay hidden. +func TestInitiatorReportsDivergence(t *testing.T) { + reg, signers := endorserSet(t, 2) + init := newTestInitiator(reg, 2) + + first := signers[0].Address().Hex() + answer := func(party view.Identity) (*EndorseResponse, error) { + s := byAddress(signers)[string(party)] + if string(party) == first { + return endorsedBy(t, s, endorsedDelta(t)), nil + } + + return endorsedBy(t, s, divergentDelta(t)), nil + } + + _, err := init.Collect(context.Background(), answer) + require.Error(t, err) + require.ErrorIs(t, err, ErrInsufficientEndorsements) + assert.ErrorIs(t, err, ErrDivergentDeltas) +} + +// TestInitiatorRejectsAnUnusableAnchor checks the initiator fails before contacting anyone when it +// cannot derive the anchor it would have to bind the replies to. +func TestInitiatorRejectsAnUnusableAnchor(t *testing.T) { reg, _ := endorserSet(t, 2) - init := NewInitiator(reg, 2, &fakeDeltaBuilder{err: assert.AnError}, testDomain(), validRequest()) + req := validRequest() + req.Anchor = "not-hex" + init := NewInitiator(reg, 2, testDomain(), req) + asked := false _, err := init.Collect(context.Background(), func(view.Identity) (*EndorseResponse, error) { + asked = true + return nil, assert.AnError }) require.Error(t, err) + assert.False(t, asked, "no endorser is contacted for a request whose anchor cannot be parsed") +} + +// TestInitiatorStopsOnCancellation checks a cancelled context ends the round rather than working +// through the rest of the endorser set. +func TestInitiatorStopsOnCancellation(t *testing.T) { + reg, signers := endorserSet(t, 3) + init := newTestInitiator(reg, 3) + + ctx, cancel := context.WithCancel(context.Background()) + asked := 0 + answer := answerWith(t, byAddress(signers), endorsedDelta(t)) + _, err := init.Collect(ctx, func(party view.Identity) (*EndorseResponse, error) { + asked++ + cancel() + + return answer(party) + }) + require.Error(t, err) + assert.Equal(t, 1, asked, "collection stops at the first check after cancellation") } diff --git a/x/token/services/network/evm/endorsement/ledger.go b/x/token/services/network/evm/endorsement/ledger.go index f358722d21..c2d9c107bf 100644 --- a/x/token/services/network/evm/endorsement/ledger.go +++ b/x/token/services/network/evm/endorsement/ledger.go @@ -34,7 +34,9 @@ const getTokenMethod = "getToken(bytes32)" // #nosec G101 -- ABI method signatur // Ledger satisfies token.Ledger, so it can be passed straight to // Validator.UnmarshallAndVerifyWithMetadata. type Ledger struct { - ctx context.Context + // GetState takes no context (driver.GetStateFnc has none), so the one to read the chain with is + // captured here. A Ledger is built per request and used only for it, so it stays short-lived. + ctx context.Context //nolint:containedctx client client.EVMClient tokenState client.Address blockTag string diff --git a/x/token/services/network/evm/endorsement/messages.go b/x/token/services/network/evm/endorsement/messages.go index f88e32284b..bd9e426628 100644 --- a/x/token/services/network/evm/endorsement/messages.go +++ b/x/token/services/network/evm/endorsement/messages.go @@ -7,8 +7,10 @@ SPDX-License-Identifier: Apache-2.0 package endorsement import ( - token2 "github.com/LFDT-Panurus/panurus/token" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" + + token2 "github.com/LFDT-Panurus/panurus/token" + "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta" ) // Message types stamped on the endorsement envelopes exchanged over an FSC session. They travel in @@ -17,7 +19,7 @@ import ( const ( // TypeEndorseRequest is the initiator's request to an endorser. TypeEndorseRequest = "evm.endorse.request" - // TypeEndorseResponse is the endorser's reply carrying its signature. + // TypeEndorseResponse is the endorser's reply carrying the delta it built and its signature. TypeEndorseResponse = "evm.endorse.response" ) @@ -54,12 +56,22 @@ func (r *EndorseRequest) Validate() error { return nil } -// EndorseResponse is the endorser's reply. On success it carries the 65-byte {r,s,v} signature over -// the EIP-712 digest the endorser recomputed, and the Ethereum address it signed with (a hint for -// the initiator; the initiator still recovers the address from the signature and does not trust this -// field for authorization). On failure Err carries the reason and Signature is empty. +// EndorseResponse is the endorser's reply. On success it carries the StateDelta this endorser +// translated the request into, the 65-byte {r,s,v} signature over that delta's EIP-712 digest, and +// the Ethereum address it signed with (a hint for the initiator; the initiator still recovers the +// address from the signature and does not trust this field for authorization). On failure Err +// carries the reason and the rest is empty. +// +// The delta travels back rather than being rebuilt by the initiator, mirroring Fabric, where the +// RWSet reaches the client inside the endorsers' proposal responses. Producing it is validation work, +// and validation is what this flow delegates to the endorsers; an initiator that rebuilt it would be +// repeating every endorser's job to learn something the quorum already decided. type EndorseResponse struct { - // Signature is the 65-byte {r,s,v} endorsement over the recomputed digest, empty on failure. + // Delta is the StateDelta this endorser built from the request it validated, and the message its + // signature covers. The initiator takes the delta from here, requires a threshold of endorsers to + // have signed the same one, and encodes that into the transaction. + Delta *statedelta.StateDelta `json:"delta,omitempty"` + // Signature is the 65-byte {r,s,v} endorsement over Delta's digest, empty on failure. Signature []byte `json:"signature,omitempty"` // EndorserAddress is the 0x-prefixed address the endorser signed with, for diagnostics. EndorserAddress string `json:"endorser_address,omitempty"` @@ -67,7 +79,9 @@ type EndorseResponse struct { Err string `json:"err,omitempty"` } -// Error returns the endorser's failure as an error, or nil when the response is a success. +// Error returns the endorser's failure as an error, or nil when the response is a success. A reply +// carrying a signature but no delta is a failure too: there is nothing for the initiator to verify +// the signature against, so it cannot be counted toward a quorum. func (r *EndorseResponse) Error() error { if len(r.Err) != 0 { return errors.New(r.Err) @@ -75,6 +89,9 @@ func (r *EndorseResponse) Error() error { if len(r.Signature) == 0 { return errors.New("endorse response: neither signature nor error present") } + if r.Delta == nil { + return errors.New("endorse response: signature without a state delta") + } return nil } diff --git a/x/token/services/network/evm/endorsement/messages_test.go b/x/token/services/network/evm/endorsement/messages_test.go index ac3527171a..68e709ee92 100644 --- a/x/token/services/network/evm/endorsement/messages_test.go +++ b/x/token/services/network/evm/endorsement/messages_test.go @@ -11,9 +11,11 @@ import ( "strings" "testing" - token2 "github.com/LFDT-Panurus/panurus/token" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + token2 "github.com/LFDT-Panurus/panurus/token" + "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta" ) func sampleRequest() *EndorseRequest { @@ -66,7 +68,7 @@ func TestEndorseRequestValidate(t *testing.T) { func TestEndorseResponseError(t *testing.T) { t.Run("success has no error", func(t *testing.T) { - require.NoError(t, (&EndorseResponse{Signature: []byte{0x01}}).Error()) + require.NoError(t, (&EndorseResponse{Delta: &statedelta.StateDelta{}, Signature: []byte{0x01}}).Error()) }) t.Run("failure surfaces the reason", func(t *testing.T) { err := (&EndorseResponse{Err: "unauthorized"}).Error() @@ -76,4 +78,36 @@ func TestEndorseResponseError(t *testing.T) { t.Run("empty response is an error", func(t *testing.T) { require.Error(t, (&EndorseResponse{}).Error()) }) + t.Run("a signature without a delta is an error", func(t *testing.T) { + // There would be nothing for the initiator to verify the signature against, and nothing to + // encode into the transaction. + require.Error(t, (&EndorseResponse{Signature: []byte{0x01}}).Error()) + }) +} + +// TestEndorseResponseRoundTripsTheDelta is the wire-format guard for the other half of the flow: the +// initiator builds no delta of its own, so a delta that does not survive the session unchanged means +// no quorum can ever assemble. +func TestEndorseResponseRoundTripsTheDelta(t *testing.T) { + want := &EndorseResponse{ + Delta: &statedelta.StateDelta{ + Anchor: [32]byte{0xC1}, + SpentRefs: [][32]byte{{0x01}, {0x02}}, + Outputs: []statedelta.OutputToken{{TokenID: [32]byte{0x03}, SNMarker: [32]byte{0x04}, TokenData: []byte("tok")}}, + MetadataKeys: [][32]byte{{0x05}}, + MetadataVals: [][]byte{[]byte("meta")}, + TokenRequestHash: [32]byte{0x06}, + PublicParamsHash: [32]byte{0x07}, + PublicParamsVersion: 9, + }, + Signature: []byte{0xAA, 0xBB}, + EndorserAddress: "0x0000000000000000000000000000000000000001", + } + + raw, err := json.Marshal(want) + require.NoError(t, err) + + var got EndorseResponse + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, *want, got) } diff --git a/x/token/services/network/evm/endorsement/responder.go b/x/token/services/network/evm/endorsement/responder.go index 58f457055c..c1f1d0a403 100644 --- a/x/token/services/network/evm/endorsement/responder.go +++ b/x/token/services/network/evm/endorsement/responder.go @@ -16,6 +16,7 @@ import ( token2 "github.com/LFDT-Panurus/panurus/token" session2 "github.com/LFDT-Panurus/panurus/token/services/utils/json/session" "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/eip712" + "github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta" ) // receiveTimeout bounds how long a responder waits for the request on its session before giving up. @@ -82,44 +83,56 @@ func (r *Responder) Call(context view.Context) (any, error) { } // Handle runs the endorsement decision for one request from the authenticated caller and returns the -// response to send back. It never returns an error: a refusal is a well-formed EndorseResponse with -// Err set, so the initiator always learns the outcome. Splitting it out from Call keeps the decision +// response to send back: the delta this endorser translated the request into, and its signature over +// that delta's digest. It never returns an error: a refusal is a well-formed EndorseResponse with Err +// set, so the initiator always learns the outcome. Splitting it out from Call keeps the decision // testable without a session. +// +// The delta is part of the reply because the initiator does not build one. It has to encode a delta +// into the transaction, and producing one means validating the request, which is precisely the work +// this flow delegates to endorsers, so the endorsers hand back what they signed. func (r *Responder) Handle(ctx context.Context, caller view.Identity, req *EndorseRequest) *EndorseResponse { - sig, err := r.endorse(ctx, caller, req) + delta, sig, err := r.endorse(ctx, caller, req) if err != nil { return &EndorseResponse{Err: err.Error()} } - return &EndorseResponse{Signature: sig, EndorserAddress: r.signer.Address().Hex()} + return &EndorseResponse{Delta: delta, Signature: sig, EndorserAddress: r.signer.Address().Hex()} } // endorse is the decision proper: authorize, then validate-and-translate through the shared factory, -// then sign. It returns the signature or the first failure. The digest is derived here, from the -// delta this endorser built, never taken from the request. -func (r *Responder) endorse(ctx context.Context, caller view.Identity, req *EndorseRequest) ([]byte, error) { +// then sign. It returns the delta and its signature, or the first failure. The digest is derived +// here, from the delta this endorser built, never taken from the request. +func (r *Responder) endorse( + ctx context.Context, + caller view.Identity, + req *EndorseRequest, +) (*statedelta.StateDelta, []byte, error) { if err := req.Validate(); err != nil { - return nil, err + return nil, nil, err } if err := r.authorizer.Authorize(caller); err != nil { - return nil, err + return nil, nil, err } // A TMS this endorser cannot resolve is one it does not serve, so refusing here is the same check // the fixed TMS identity used to make, expressed through what it can actually validate. factory, err := r.factoryFor(req.TMSID) if err != nil { - return nil, errors.Wrapf(err, "this endorser does not serve tms [%s]", req.TMSID) + return nil, nil, errors.Wrapf(err, "this endorser does not serve tms [%s]", req.TMSID) } delta, err := factory.Build(ctx, req) if err != nil { - return nil, err + return nil, nil, err } - digest := eip712.Digest(r.domain, delta) + sig, err := r.signer.Sign(eip712.Digest(r.domain, delta)) + if err != nil { + return nil, nil, err + } - return r.signer.Sign(digest) + return delta, sig, nil } // compile-time check that *token.Validator satisfies RequestValidator, so the production wiring diff --git a/x/token/services/network/evm/endorsement/service.go b/x/token/services/network/evm/endorsement/service.go index 3ed7c618c0..fe62cd136d 100644 --- a/x/token/services/network/evm/endorsement/service.go +++ b/x/token/services/network/evm/endorsement/service.go @@ -36,7 +36,6 @@ type ViewRegistry interface { type Service struct { registry *Registry threshold int - factory *DeltaFactory domain eip712.Domain viewManager ViewManager } @@ -47,7 +46,6 @@ type Service struct { func NewService( registry *Registry, threshold int, - factory *DeltaFactory, domain eip712.Domain, viewManager ViewManager, ) (*Service, error) { @@ -57,9 +55,6 @@ func NewService( if threshold < 1 || threshold > registry.Len() { return nil, errors.Errorf("endorsement service: threshold %d out of range [1,%d]", threshold, registry.Len()) } - if factory == nil { - return nil, errors.New("endorsement service: nil delta factory") - } if viewManager == nil { return nil, errors.New("endorsement service: nil view manager") } @@ -67,7 +62,6 @@ func NewService( return &Service{ registry: registry, threshold: threshold, - factory: factory, domain: domain, viewManager: viewManager, }, nil @@ -81,7 +75,7 @@ func (s *Service) Endorse(context view.Context, req *EndorseRequest) (*Result, e } boxed, err := s.viewManager.InitiateView( context.Context(), - NewInitiator(s.registry, s.threshold, s.factory, s.domain, req), + NewInitiator(s.registry, s.threshold, s.domain, req), ) if err != nil { return nil, errors.Wrap(err, "failed to run endorsement initiator") diff --git a/x/token/services/network/evm/endorsement/service_test.go b/x/token/services/network/evm/endorsement/service_test.go index 3a79429f7b..81f3bfb759 100644 --- a/x/token/services/network/evm/endorsement/service_test.go +++ b/x/token/services/network/evm/endorsement/service_test.go @@ -32,8 +32,7 @@ func (m *stubViewManager) InitiateView(context.Context, view.View) (any, error) func newService(t *testing.T, vm ViewManager, threshold int) *Service { t.Helper() reg, _ := endorserSet(t, 3) - factory := NewDeltaFactory(&fakeValidator{}, &fakePP{}, nil, addr(0xAA), "") - s, err := NewService(reg, threshold, factory, testDomain(), vm) + s, err := NewService(reg, threshold, testDomain(), vm) require.NoError(t, err) return s @@ -85,12 +84,11 @@ func TestServiceEndorseRejectsUnexpectedResultType(t *testing.T) { func TestNewServiceValidatesThreshold(t *testing.T) { reg, _ := endorserSet(t, 3) - factory := NewDeltaFactory(&fakeValidator{}, &fakePP{}, nil, addr(0xAA), "") for _, threshold := range []int{0, 4} { - _, err := NewService(reg, threshold, factory, testDomain(), &stubViewManager{}) + _, err := NewService(reg, threshold, testDomain(), &stubViewManager{}) require.Error(t, err, "threshold %d must be rejected", threshold) } - _, err := NewService(reg, 2, factory, testDomain(), &stubViewManager{}) + _, err := NewService(reg, 2, testDomain(), &stubViewManager{}) require.NoError(t, err) } diff --git a/x/token/services/network/evm/eth_network_driver_design.md b/x/token/services/network/evm/eth_network_driver_design.md index ec984899a3..b765b27532 100644 --- a/x/token/services/network/evm/eth_network_driver_design.md +++ b/x/token/services/network/evm/eth_network_driver_design.md @@ -106,8 +106,9 @@ sequenceDiagram E->>E: assert local ppHash/version == delta E->>E: persist validation record E->>E: sign EIP-712(StateDelta) - E-->>I: {signature, endorserAddress} + E-->>I: {stateDelta, signature, endorserAddress} end + I->>I: bind delta to anchor, recover signers, group by delta I->>I: verify threshold, ABI-encode typed applyStateDelta, sign eth tx I->>N: eth_sendRawTransaction N->>TS: applyStateDelta(delta, signatures) @@ -551,7 +552,8 @@ type Envelope struct { ## 6. Endorsement (initiator + responder) Mirrors `fabric/endorsement/fsc` (initiator.go + responder.go); artifact is a `StateDelta`, signature is -EIP-712. Wired as a lazy `ServiceProvider` keyed by `TMSID` (the `esp.go` pattern). +EIP-712. Wired as a lazy `ServiceProvider` keyed by `TMSID` (the `esp.go` pattern). As in Fabric, the +artifact is produced by the endorsers and travels back to the initiator with their signatures (§6.5). ### 6.1 Identity registry (address ↔ FSC identity) @@ -563,7 +565,7 @@ the on-chain endorser set. The EndorsementVerifier set alone yields addresses, w ### 6.2 Responder (`evm/endorsement` responder view) -`receive → authorize → validate → persist → translate → check pp → sign → reply`: +`receive → authorize → validate → persist → translate → check pp → sign → reply {delta, signature}`: 1. **Authorize** the requester by FSC identity (the FSC session authenticates the caller); require membership in the configured allowlist for the TMS (default: the TMS network's nodes). This is the EVM analog of the @@ -576,22 +578,60 @@ the on-chain endorser set. The EndorsementVerifier set alone yields addresses, w 4. **Translate** actions → `StateDelta` via the StateDelta translator; `AddPublicParamsDependency`; `CommitTokenRequest`. 5. **Check pp**: assert `delta.publicParamsVersion == VersionKeeper.GetVersion()`; refuse otherwise. -6. **Sign** the EIP-712 digest; reply `{signature, endorserAddress}`. +6. **Sign** the EIP-712 digest; reply `{stateDelta, signature, endorserAddress}`. ### 6.3 Initiator (`evm/endorsement` initiator view) -Collect signatures from the resolved FSC identities; verify threshold/policy; ABI-encode the **typed** -`applyStateDelta(delta, signatures)`; build + sign the eth tx with the submitter key (nonce + gas per §8); -wrap in `Envelope`; return to `Broadcast`. +Collect from the resolved FSC identities; **take the delta from the replies** (§6.5); verify +threshold/policy; ABI-encode the **typed** `applyStateDelta(delta, signatures)`; build + sign the eth tx +with the submitter key (nonce + gas per §8); wrap in `Envelope`; return to `Broadcast`. + +Per reply, before a signature counts: the delta's anchor must be the anchor the initiator asked about, +`StateDelta.Validate()` must hold, and the signature must recover to a registered endorser over **that +delta's** digest. Signatures are grouped by delta; the quorum is the first delta reaching threshold +distinct signers. Endorsers that answer with a different delta are ignored rather than fatal, so one +divergent node cannot break every transaction it takes part in; a shortfall where more than one delta +was seen is reported with `ErrDivergentDeltas` alongside `ErrInsufficientEndorsements`, since that is a +determinism failure in the translator rather than an availability problem. ### 6.4 Messages ```go type EndorseRequest struct { TokenRequest []byte; TMSID token.TMSID; Anchor string; Metadata map[string][]byte } -type EndorseResponse struct { Signature []byte; EndorserAddress string; Err string } +type EndorseResponse struct { Delta *statedelta.StateDelta; Signature []byte; EndorserAddress string; Err string } ``` -(No `EIP712Digest` field — endorsers recompute, §4.5.) +(No `EIP712Digest` field — endorsers recompute, §4.5. The delta travels back, §6.5.) + +### 6.5 The initiator does not build a StateDelta + +The initiator neither validates nor translates. Producing a delta means running the validator against +on-chain state, which is exactly the work this flow delegates to endorsers, so the endorsers return +what they signed and the initiator relays it. + +This mirrors Fabric, where `RequestApprovalView` sends the request as transient data and the RWSet +reaches the client inside the endorsers' proposal responses +(`token/services/network/fabric/endorsement/fsc/initiator.go`). It was raised by Angelo in the sync of +2026-08-14, against the first implementation, which had the initiator build the delta through the same +`DeltaFactory` as the responder. + +Why the initiator's own copy was worth removing rather than keeping as a cross-check: + +- **It bought no security.** The contract's rule is that a threshold of registered endorsers signed the + digest. A quorum that wanted to apply something else would not need the initiator, so a local rebuild + cannot prevent it; it only stops the initiator from paying gas for a delta it disagrees with. +- **It cost liveness.** The delta covers `PublicParamsHash` and `PublicParamsVersion`, and the digest + covers the whole struct, so an initiator reading public parameters on the other side of an update + from its endorsers computes a different digest, discards every signature as an unknown signer, and + reports a missing quorum. Its ledger read had the same shape: a client node lagging behind failed to + build a delta at all and never contacted an endorser, for a request every endorser would have signed. +- **It cost work.** Every client ran the full validation of its own request, plus a `getToken` call per + input, duplicating what N endorsers were already doing. + +What the initiator keeps is what it can check without a validator: the anchor binding and the delta's +structural invariants. It deliberately does not check `TokenRequestHash`, which covers the validator's +`TokenRequestToSign` attribute rather than the marshalled request the initiator holds; reproducing it +would mean running the validation again. --- diff --git a/x/token/services/network/evm/eth_network_driver_implementation_plan.md b/x/token/services/network/evm/eth_network_driver_implementation_plan.md index bd7eae204d..50ebb9d827 100644 --- a/x/token/services/network/evm/eth_network_driver_implementation_plan.md +++ b/x/token/services/network/evm/eth_network_driver_implementation_plan.md @@ -323,21 +323,25 @@ Gate: deterministic delta bytes; a Go-signed delta verifies on the Week-2 contra go-ethereum. Selectors cross-checked with `cast sig`. - [x] `endorsement/registry.go`: address ↔ `view.Identity`, both directions; rejects duplicate address or identity (the distinct-signer rule starts at construction). -- [x] `endorsement/messages.go`: `EndorseRequest`/`EndorseResponse`, **no digest field** (a wire-format - test guards it), carried in the versioned session envelope. +- [x] `endorsement/messages.go`: `EndorseRequest`/`EndorseResponse`, **no digest field on the request** + and **the delta on the response** (wire-format tests guard both), carried in the versioned session + envelope. - [x] `endorsement/ledger.go`: `token.Ledger` backed by `getToken@finalized` via the mock/real EVMClient (the `EVMClient` counterfeiter mock, deferred since 1.2, is generated here). - [x] `endorsement/responder.go` (template `fabric/.../responder.go`): authorize (allowlist) → validate (`UnmarshallAndVerifyWithMetadata` + `eth_call` `getToken` ledger) → translate → sign. **No precomputed digest**: the endorser recomputes it from the validated actions. -- [x] `endorsement/delta.go`: `DeltaFactory`, the single validate-and-translate path the responder and - the initiator both build through (byte-identical deltas by shared construction, §4.4). -- [x] `endorsement/initiator.go`: collect over FSC sessions; recover each signature to a **distinct - registered** endorser over the locally-computed digest before counting it; threshold/uniqueness. +- [x] `endorsement/delta.go`: `DeltaFactory`, the responder's validate-and-translate path, the one every + endorser runs (byte-identical deltas by shared construction, §4.4). +- [x] `endorsement/initiator.go`: collect over FSC sessions; **take the delta from the replies** (§6.5), + bind it to the request anchor, then recover each signature to a **distinct registered** endorser + over that delta's digest before counting it; group by delta; threshold/uniqueness. - [x] `endorsement/service.go`: `Service.Endorse` entry point (initiates the initiator view) + `RegisterEndorser`; the driver envelope now carries the endorsed delta + collected signatures. - [x] Tests: tampered-delta refusal (no blind-sign), 2-of-3 assembly over in-memory sessions, - authorization reject, duplicate/unknown/wrong-digest signature rejection, ledger + ABI + registry. + authorization reject, duplicate/unknown/wrong-digest signature rejection, wrong-anchor and + malformed deltas discarded, a divergent endorser outvoted rather than fatal, ledger + ABI + + registry. Gate MET: `gate_test.go` drives the real initiator + responders over in-memory sessions to assemble a 2-of-3 quorum and pins it to a committed fixture; `contracts/test/Endorsement2ofN.t.sol` verifies that diff --git a/x/token/services/network/evm/go.mod b/x/token/services/network/evm/go.mod index 3ad025b39a..4c3b0c46d1 100644 --- a/x/token/services/network/evm/go.mod +++ b/x/token/services/network/evm/go.mod @@ -8,7 +8,7 @@ require ( github.com/IBM/mathlib v0.3.0 github.com/LFDT-Panurus/panurus v0.0.0-00010101000000-000000000000 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 - github.com/hyperledger-labs/fabric-smart-client v0.16.0 + github.com/hyperledger-labs/fabric-smart-client v0.17.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel/trace v1.44.0 golang.org/x/crypto v0.54.0 diff --git a/x/token/services/network/evm/go.sum b/x/token/services/network/evm/go.sum index 9b2951c17f..37742fef6c 100644 --- a/x/token/services/network/evm/go.sum +++ b/x/token/services/network/evm/go.sum @@ -86,8 +86,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hyperledger-labs/fabric-smart-client v0.16.0 h1:JZtM2pd174Wo3rOJJIEN7dgvjpsp+K2J2lqCifVulJc= -github.com/hyperledger-labs/fabric-smart-client v0.16.0/go.mod h1:Xfm18BI6WuGU3IG++j8AyiThC74ZBDjd+VI5zoH9edw= +github.com/hyperledger-labs/fabric-smart-client v0.17.0 h1:6BKrkd0PFuDM0GMjL5brVl+wF3pnxj+G390XNCbWjRo= +github.com/hyperledger-labs/fabric-smart-client v0.17.0/go.mod h1:Xfm18BI6WuGU3IG++j8AyiThC74ZBDjd+VI5zoH9edw= github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2 h1:B1Nt8hKb//KvgGRprk0h1t4lCnwhE9/ryb1WqfZbV+M= github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2/go.mod h1:X+DIyUsaTmalOpmpQfIvFZjKHQedrURQ5t4YqquX7lE= github.com/hyperledger/fabric-lib-go v1.1.5-0.20260708100132-163bcc919208 h1:qA49XOMwyNPxggVyW+HSDAg7I3GdtlGWDTbh40/rwZk=