Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/services/network-ethereum.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion x/token/services/network/evm/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 7 additions & 37 deletions x/token/services/network/evm/endorsement/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
26 changes: 16 additions & 10 deletions x/token/services/network/evm/endorsement/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions x/token/services/network/evm/endorsement/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
41 changes: 7 additions & 34 deletions x/token/services/network/evm/endorsement/esp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,7 +34,6 @@ type ServiceFactory struct {
blockTag string
publicParams PublicParamsProvider
viewManager ViewManager
resolveTMS TMSResolver

mu sync.Mutex
services map[string]*Service
Expand All @@ -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.
Expand All @@ -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())
Expand All @@ -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
}
Expand All @@ -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)
}
Expand Down
83 changes: 32 additions & 51 deletions x/token/services/network/evm/endorsement/esp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
}

Expand All @@ -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 },
}
Expand All @@ -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)
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion x/token/services/network/evm/endorsement/fakes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions x/token/services/network/evm/endorsement/gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading