Skip to content

Commit dd94c0c

Browse files
committed
feat(evm): endorsers return the delta, the initiator stops building one
The initiator ran the full validator on every request through the same DeltaFactory as the responder, to get a digest to verify signatures against and a delta to put in the transaction. That is the work this flow delegates, and it bought nothing: the contract's rule is that a threshold of registered endorsers signed the digest, so a quorum wanting to apply something else would never need the initiator. It did cost something. The delta covers the public parameters hash and version, so an initiator reading parameters on the other side of an update from its endorsers computed a different digest and discarded every signature as an unknown signer. Its ledger read had the same shape: a lagging node failed to build a delta and never contacted an endorser at all. EndorseResponse now carries the delta the endorser signed, the way a Fabric proposal response carries the RWSet. The initiator binds each delta to the anchor it asked about, checks the invariants, recovers the signature over that delta's digest, and groups by delta so a divergent endorser is outvoted rather than fatal. A shortfall where more than one delta was seen reports ErrDivergentDeltas next to ErrInsufficientEndorsements, since that is a determinism failure and not an availability one. Raised by Angelo in the sync of 2026-08-14. Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
1 parent aa35233 commit dd94c0c

17 files changed

Lines changed: 556 additions & 288 deletions

docs/services/network-ethereum.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,16 @@ sequenceDiagram
310310
E1->>E1: Validate token request
311311
E1->>E1: Compute state delta
312312
E1->>E1: Sign state delta
313-
E1-->>Driver: Signature 1
313+
E1-->>Driver: State delta + signature 1
314314
and
315315
Driver->>E2: Request endorsement
316316
E2->>E2: Validate token request
317317
E2->>E2: Compute state delta
318318
E2->>E2: Sign state delta
319-
E2-->>Driver: Signature 2
319+
E2-->>Driver: State delta + signature 2
320320
end
321321
322-
Driver->>Driver: Assemble state update + signatures
322+
Driver->>Driver: Check the deltas agree, assemble with signatures
323323
324324
Note over Driver,State: On-Chain Execution Phase
325325
Driver->>Node: eth_sendRawTransaction

x/token/services/network/evm/driver.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,6 @@ func (d *Driver) installEndorsement(n *Network, config *Config, evmClient client
274274
BlockTag: config.Finality.BlockTag,
275275
PublicParams: pp.NewChainProvider(evmClient, tokenState, config.Finality.BlockTag),
276276
ViewManager: d.viewManager,
277-
TMS: d.resolveTMS,
278277
})
279278
if err != nil {
280279
return err

x/token/services/network/evm/endorsement/delta.go

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ import (
1818
"github.com/LFDT-Panurus/panurus/x/token/services/network/evm/statedelta"
1919
)
2020

21-
// DeltaFactory turns a validated token request into the StateDelta both sides of the flow work with:
22-
// the responder signs its EIP-712 digest, the initiator assembles the signatures over it and encodes
23-
// it into the transaction. Sharing one construction path is how the §4.4 determinism guarantee (every
24-
// endorser and the initiator produce byte-identical deltas) is met, by running the same code rather
25-
// than trusting independent reimplementations to agree.
21+
// DeltaFactory turns a validated token request into the StateDelta an endorser signs and returns. It
22+
// belongs to the responder alone: the initiator neither validates nor translates, it takes the delta
23+
// from the endorsers' replies. The §4.4 determinism guarantee (every endorser produces byte-identical
24+
// deltas) is met by them all running this one construction path, rather than by trusting independent
25+
// reimplementations to agree.
2626
//
2727
// Build validates the request against on-chain state (read through the getToken ledger at blockTag),
2828
// then translates the validated actions with the StateDelta translator, binding the public
@@ -56,35 +56,8 @@ func NewDeltaFactory(
5656
}
5757
}
5858

59-
// ResolveValidator adapts a function that looks a validator up into a RequestValidator, so a delta
60-
// factory resolves one per request instead of holding the one it was built with.
61-
//
62-
// The validator a TMS hands out is derived from that TMS's public parameters, and updating parameters
63-
// evicts the management service so that the next caller builds a new one. A validator captured at
64-
// construction therefore keeps checking actions against parameters that no longer describe the
65-
// network, and so does a validator re-read from a management service captured at construction: both
66-
// have to be resolved from the id, per request. Getting this wrong is not subtle in its effects -
67-
// after an update that authorises a new issuer, that issuer's every request is rejected as
68-
// unauthorised, by a node that has already logged the new parameters.
69-
type ResolveValidator func() (RequestValidator, error)
70-
71-
// UnmarshallAndVerifyWithMetadata resolves the current validator and delegates to it.
72-
func (r ResolveValidator) UnmarshallAndVerifyWithMetadata(
73-
ctx context.Context,
74-
ledger token2.Ledger,
75-
anchor token2.RequestAnchor,
76-
raw []byte,
77-
) ([]any, map[string][]byte, error) {
78-
validator, err := r()
79-
if err != nil {
80-
return nil, nil, errors.Wrap(err, "failed to resolve the validator")
81-
}
82-
83-
return validator.UnmarshallAndVerifyWithMetadata(ctx, ledger, anchor, raw)
84-
}
85-
86-
// Build validates req against on-chain state and returns the StateDelta to sign or assemble. A
87-
// validation failure is wrapped with ErrValidation so callers can classify it.
59+
// Build validates req against on-chain state and returns the StateDelta to sign. A validation
60+
// failure is wrapped with ErrValidation so callers can classify it.
8861
func (f *DeltaFactory) Build(ctx context.Context, req *EndorseRequest) (*statedelta.StateDelta, error) {
8962
ppRaw, ppVersion, err := f.pp.PublicParams(ctx)
9063
if err != nil {
@@ -125,6 +98,3 @@ func (f *DeltaFactory) Build(ctx context.Context, req *EndorseRequest) (*statede
12598

12699
return tr.StateDelta()
127100
}
128-
129-
// compile-time check that DeltaFactory satisfies the DeltaBuilder the initiator depends on.
130-
var _ DeltaBuilder = (*DeltaFactory)(nil)

x/token/services/network/evm/endorsement/doc.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,23 @@ SPDX-License-Identifier: Apache-2.0
1616
// the request.
1717
// - Responder (responder.go) is the endorser side: receive → authorize (allowlist, the EVM analog
1818
// of the Fabric MSP/ACL check) → validate the request against on-chain state (ledger.go, getToken
19-
// at a finalized block tag) → translate to a StateDelta → sign its EIP-712 digest. It recomputes
20-
// the digest from the validated actions and never signs one handed to it (design §4.5).
19+
// at a finalized block tag) → translate to a StateDelta → sign its EIP-712 digest → reply with
20+
// both. It recomputes the digest from the validated actions and never signs one handed to it
21+
// (design §4.5).
2122
// - Initiator (initiator.go) is the collector side: open a session to each registered endorser,
2223
// gather replies, and count a signature only after recovering it to a distinct registered endorser
23-
// over the digest it computed itself, mirroring the contract's threshold and distinct-signer rules.
24-
// - DeltaFactory (delta.go) is the single validate-and-translate path both sides build the delta
25-
// through, so every endorser and the initiator produce byte-identical deltas (the §4.4
26-
// determinism guarantee), and Service (service.go) is the per-TMS entry point RequestApproval
27-
// drives.
24+
// over the digest of the delta that came back with it, mirroring the contract's threshold and
25+
// distinct-signer rules.
26+
// - DeltaFactory (delta.go) is the responder's validate-and-translate path, the one every endorser
27+
// runs so they all produce byte-identical deltas (the §4.4 determinism guarantee), and Service
28+
// (service.go) is the per-TMS entry point RequestApproval drives.
2829
//
29-
// The request carries no precomputed digest (messages.go): endorsers recompute it. The end-to-end
30-
// guarantee is pinned by the gate (gate_test.go + contracts/test/Endorsement2ofN.t.sol): a 2-of-3
31-
// quorum assembled over in-memory sessions verifies on the EndorsementVerifier on-chain.
30+
// Both directions of the wire follow the same principle: the party that does the work is the party
31+
// that decides what it means. The request carries no precomputed digest, so endorsers recompute
32+
// rather than blind-sign; the response carries the delta, so the initiator relays rather than
33+
// revalidates. That is the shape Fabric already has, where the request travels as transient data and
34+
// the RWSet comes back inside the endorsers' proposal responses.
35+
//
36+
// The end-to-end guarantee is pinned by the gate (gate_test.go + contracts/test/Endorsement2ofN.t.sol):
37+
// a 2-of-3 quorum assembled over in-memory sessions verifies on the EndorsementVerifier on-chain.
3238
package endorsement

x/token/services/network/evm/endorsement/errors.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,14 @@ var (
3939
// contract counts distinct signers only, so the initiator must not assemble a quorum that would be
4040
// rejected on-chain.
4141
ErrDuplicateSigner = errors.New("duplicate endorser signature")
42+
43+
// ErrDeltaMismatch is returned when an endorser's delta does not belong to the request it was
44+
// asked about, or is not structurally well formed. The signature over it is not counted.
45+
ErrDeltaMismatch = errors.New("state delta does not match the request")
46+
47+
// ErrDivergentDeltas accompanies ErrInsufficientEndorsements when endorsers answered but signed
48+
// different deltas for the same request. Endorsers that validated the same request must translate
49+
// it identically, so this means the translation is not deterministic, not that endorsers were
50+
// unavailable.
51+
ErrDivergentDeltas = errors.New("endorsers disagree on the state delta")
4252
)

x/token/services/network/evm/endorsement/esp.go

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@ import (
1818

1919
// ServiceFactory builds the endorsement service for a TMS, and caches it.
2020
//
21-
// The service is per TMS rather than per network because validating a token request needs that TMS's
22-
// validator, which knows its token driver and public parameters. Everything else it needs (the
23-
// endorser set, the threshold, the chain, this node's signing key) is network-wide and is resolved
24-
// once, here.
21+
// The service is keyed by TMS because that is the unit RequestApproval works in, but it no longer
22+
// holds anything derived from a TMS: the initiator collects signatures and takes the delta from the
23+
// endorsers, so nothing on that side needs a validator. Validation lives entirely in the responder,
24+
// which resolves its TMS when a request arrives.
2525
//
26-
// The factory takes a TMS id and resolves the management service itself, per request. Holding the
27-
// service would be wrong: updating public parameters evicts the cached management service and the next
28-
// caller gets a rebuilt one, so a captured pointer keeps serving the parameters that were current when
29-
// it was captured, and no amount of re-asking it for a validator changes that.
26+
// What the factory does hold is network-wide and resolved once: the endorser set, the threshold, the
27+
// chain, the EIP-712 domain, and the parameters a delta is bound to.
3028
type ServiceFactory struct {
3129
registry *Registry
3230
threshold int
@@ -36,7 +34,6 @@ type ServiceFactory struct {
3634
blockTag string
3735
publicParams PublicParamsProvider
3836
viewManager ViewManager
39-
resolveTMS TMSResolver
4037

4138
mu sync.Mutex
4239
services map[string]*Service
@@ -60,8 +57,6 @@ type FactoryConfig struct {
6057
PublicParams PublicParamsProvider
6158
// ViewManager runs the initiator.
6259
ViewManager ViewManager
63-
// TMS resolves a management service from its id, on every request rather than once.
64-
TMS TMSResolver
6560
}
6661

6762
// NewServiceFactory returns a factory for the given network.
@@ -78,9 +73,6 @@ func NewServiceFactory(cfg FactoryConfig) (*ServiceFactory, error) {
7873
if cfg.PublicParams == nil {
7974
return nil, errors.New("endorsement factory: nil public parameters provider")
8075
}
81-
if cfg.TMS == nil {
82-
return nil, errors.New("endorsement factory: nil tms resolver")
83-
}
8476
if cfg.Threshold < 1 || cfg.Threshold > cfg.Registry.Len() {
8577
return nil, errors.Errorf("endorsement factory: threshold %d out of range [1,%d]",
8678
cfg.Threshold, cfg.Registry.Len())
@@ -95,7 +87,6 @@ func NewServiceFactory(cfg FactoryConfig) (*ServiceFactory, error) {
9587
blockTag: cfg.BlockTag,
9688
publicParams: cfg.PublicParams,
9789
viewManager: cfg.ViewManager,
98-
resolveTMS: cfg.TMS,
9990
services: map[string]*Service{},
10091
}, nil
10192
}
@@ -113,25 +104,7 @@ func (f *ServiceFactory) ForTMS(tmsID token2.TMSID) (*Service, error) {
113104
return service, nil
114105
}
115106

116-
// Both the management service and the validator are resolved per request. The service cached here
117-
// lives for the life of the node, but an endorsed setup delta replaces the management service
118-
// underneath it, and only a fresh one knows the new public parameters.
119-
resolve := ResolveValidator(func() (RequestValidator, error) {
120-
tms, err := f.resolveTMS(tmsID)
121-
if err != nil {
122-
return nil, errors.Wrapf(err, "failed to resolve tms [%s]", tmsID)
123-
}
124-
125-
return tms.Validator()
126-
})
127-
128-
service, err := NewService(
129-
f.registry,
130-
f.threshold,
131-
NewDeltaFactory(resolve, f.publicParams, f.client, f.tokenState, f.blockTag),
132-
f.domain,
133-
f.viewManager,
134-
)
107+
service, err := NewService(f.registry, f.threshold, f.domain, f.viewManager)
135108
if err != nil {
136109
return nil, errors.Wrapf(err, "endorsement factory: failed to build the service for [%s]", tmsID)
137110
}

x/token/services/network/evm/endorsement/esp_test.go

Lines changed: 32 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ func testFactoryConfig(t *testing.T) FactoryConfig {
3232
TokenState: addr(0xAA),
3333
PublicParams: &fakePP{raw: []byte("pp"), version: 1},
3434
ViewManager: &stubViewManager{},
35-
TMS: func(token2.TMSID) (*token2.ManagementService, error) {
36-
return nil, assert.AnError
37-
},
3835
}
3936
}
4037

@@ -51,7 +48,6 @@ func TestNewServiceFactoryValidates(t *testing.T) {
5148
"no client": func(c *FactoryConfig) { c.Client = nil },
5249
"no view manager": func(c *FactoryConfig) { c.ViewManager = nil },
5350
"no public parameters": func(c *FactoryConfig) { c.PublicParams = nil },
54-
"no tms resolver": func(c *FactoryConfig) { c.TMS = nil },
5551
"zero threshold": func(c *FactoryConfig) { c.Threshold = 0 },
5652
"threshold too high": func(c *FactoryConfig) { c.Threshold = 3 },
5753
}
@@ -65,8 +61,8 @@ func TestNewServiceFactoryValidates(t *testing.T) {
6561
}
6662
}
6763

68-
// TestForTMSRejectsAnEmptyID checks the factory does not build a service it could never resolve a TMS
69-
// for, since the TMS is where the validator comes from.
64+
// TestForTMSRejectsAnEmptyID checks the factory does not build a service for an id it could not route
65+
// a request under.
7066
func TestForTMSRejectsAnEmptyID(t *testing.T) {
7167
f, err := NewServiceFactory(testFactoryConfig(t))
7268
require.NoError(t, err)
@@ -75,68 +71,53 @@ func TestForTMSRejectsAnEmptyID(t *testing.T) {
7571
require.Error(t, err)
7672
}
7773

78-
// TestForTMSResolvesTheTMSPerRequest pins the reason the initiator holds a TMS id and not a TMS.
74+
// TestForTMSCachesPerTMS checks the service is built once and reused. It holds nothing derived from a
75+
// TMS any more (the initiator collects signatures and takes the delta from the endorsers), so the
76+
// cache is only about not rebuilding the same collaborators on every approval.
77+
func TestForTMSCachesPerTMS(t *testing.T) {
78+
f, err := NewServiceFactory(testFactoryConfig(t))
79+
require.NoError(t, err)
80+
81+
tmsID := token2.TMSID{Network: "evm", Namespace: "token"}
82+
service, err := f.ForTMS(tmsID)
83+
require.NoError(t, err)
84+
again, err := f.ForTMS(tmsID)
85+
require.NoError(t, err)
86+
assert.Same(t, service, again)
87+
}
88+
89+
// TestResponderResolvesTheTMSPerRequest pins why the responder holds a TMS id and not a TMS.
7990
//
8091
// Updating public parameters evicts the cached management service, so the next caller gets a rebuilt
8192
// one and whoever kept the old pointer keeps its old parameters. Asking that stale service for a
82-
// validator again does not help, which is what made this worth a test: the earlier fix resolved the
93+
// validator again does not help, which is what made this worth a test: an earlier fix resolved the
8394
// validator per request but from a captured service, and the symptom did not move. After an update
8495
// that authorises a new issuer, that issuer's requests were still rejected as unauthorised on a node
8596
// that had already logged the new parameters.
86-
func TestForTMSResolvesTheTMSPerRequest(t *testing.T) {
87-
cfg := testFactoryConfig(t)
88-
calls := 0
89-
cfg.TMS = func(token2.TMSID) (*token2.ManagementService, error) {
90-
calls++
91-
92-
return nil, assert.AnError
93-
}
94-
f, err := NewServiceFactory(cfg)
95-
require.NoError(t, err)
96-
97-
service, err := f.ForTMS(token2.TMSID{Network: "evm", Namespace: "token"})
97+
//
98+
// Endorsement is now the only place a validator is used at all, so this is the one path where it
99+
// matters.
100+
func TestResponderResolvesTheTMSPerRequest(t *testing.T) {
101+
f, err := NewServiceFactory(testFactoryConfig(t))
98102
require.NoError(t, err)
99-
assert.Equal(t, 0, calls, "building the service must not resolve a TMS")
100103

101-
// The same cached service, asked twice: it must go back to the resolver both times.
102-
again, err := f.ForTMS(token2.TMSID{Network: "evm", Namespace: "token"})
104+
auth, err := NewAuthorizer([]view.Identity{view.Identity(testCaller)})
103105
require.NoError(t, err)
104-
assert.Same(t, service, again, "the service itself is still cached per TMS")
105-
106-
for range 2 {
107-
_, err := service.factory.Build(t.Context(),
108-
&EndorseRequest{Anchor: "anchor", TokenRequest: []byte("tr")})
109-
require.Error(t, err)
110-
}
111-
assert.Equal(t, 2, calls, "the TMS must be resolved per request, not captured once")
112-
}
113106

114-
// TestResolveValidatorIsCalledPerBuild is the same invariant one layer down, on the adapter itself.
115-
func TestResolveValidatorIsCalledPerBuild(t *testing.T) {
116107
calls := 0
117-
resolve := ResolveValidator(func() (RequestValidator, error) {
108+
responder, err := f.NewResponder(auth, newSigner(t, 1), func(token2.TMSID) (*token2.ManagementService, error) {
118109
calls++
119110

120-
return &fakeValidator{err: assert.AnError}, nil
111+
return nil, assert.AnError
121112
})
122-
factory := NewDeltaFactory(resolve, &fakePP{raw: []byte("pp"), version: 1}, nil, addr(0xAA), "")
113+
require.NoError(t, err)
114+
assert.Equal(t, 0, calls, "building the responder must not resolve a TMS")
123115

124116
for range 2 {
125-
_, err := factory.Build(t.Context(), &EndorseRequest{Anchor: "anchor", TokenRequest: []byte("tr")})
126-
require.Error(t, err)
117+
resp := responder.Handle(t.Context(), view.Identity(testCaller), validRequest())
118+
require.Error(t, resp.Error(), "an unresolvable TMS is refused, not signed for")
127119
}
128-
assert.Equal(t, 2, calls, "the validator must be resolved per request, not captured once")
129-
}
130-
131-
// TestResolveValidatorReportsResolutionFailure checks a validator that cannot be resolved surfaces as
132-
// an error rather than a nil dereference inside validation.
133-
func TestResolveValidatorReportsResolutionFailure(t *testing.T) {
134-
resolve := ResolveValidator(func() (RequestValidator, error) { return nil, assert.AnError })
135-
factory := NewDeltaFactory(resolve, &fakePP{raw: []byte("pp"), version: 1}, nil, addr(0xAA), "")
136-
137-
_, err := factory.Build(t.Context(), &EndorseRequest{Anchor: "anchor", TokenRequest: []byte("tr")})
138-
require.Error(t, err)
139-
assert.Contains(t, err.Error(), "failed to resolve the validator")
120+
assert.Equal(t, 2, calls, "the TMS must be resolved per request, not captured once")
140121
}
141122

142123
// TestNewResponderForNeedsAKey checks a node that does not endorse cannot be turned into a responder:

x/token/services/network/evm/endorsement/gate_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func (c *gateContext) GetSession(_ view.View, party view.Identity, _ ...view.Vie
136136
// and asserts a 2-of-3 quorum is assembled whose signatures recover to distinct registered endorsers.
137137
func TestGateAssembleQuorumOverSessions(t *testing.T) {
138138
reg, responders := gateEndorsers(t, 3)
139-
initiator := NewInitiator(reg, gateThreshold, gateFactory(), gateDomain(t), gateRequest())
139+
initiator := NewInitiator(reg, gateThreshold, gateDomain(t), gateRequest())
140140

141141
ctx := &gateContext{
142142
fakeContext: fakeContext{ctx: context.Background(), me: view.Identity(gateInitiator)},
@@ -167,7 +167,7 @@ func TestGateAssembleQuorumOverSessions(t *testing.T) {
167167
// on-chain check exercises exactly what the initiator produces.
168168
func TestGateFixtureMatchesAssembly(t *testing.T) {
169169
reg, responders := gateEndorsers(t, 3)
170-
initiator := NewInitiator(reg, gateThreshold, gateFactory(), gateDomain(t), gateRequest())
170+
initiator := NewInitiator(reg, gateThreshold, gateDomain(t), gateRequest())
171171
ctx := &gateContext{
172172
fakeContext: fakeContext{ctx: context.Background(), me: view.Identity(gateInitiator)},
173173
responders: responders,

0 commit comments

Comments
 (0)