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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ cmd/token_validation_service/out/
/site/
coverage.out
/.codex/
/plan.md
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ Before implementing any task:
2. Update immediately when completing steps: `[x] Done` + brief change notes
3. Log blockers/decisions under `## Notes & Decisions`
4. Mark plan as `✅ COMPLETE` when finished
5. **Never commit `plan.md`**: it is a local scratch file, listed in `.gitignore`. Do not `git add` it,
and if a commit accidentally includes it, remove it from the commit before pushing.

### Documentation Updates (Workflow Rule)
Before marking a task complete, update or create the relevant documentation under `docs/`:
Expand Down
1 change: 1 addition & 0 deletions cmd/node/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/hyperledger-labs/fabric-smart-client/integration v0.15.1 // indirect
github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host/libp2p v0.14.2 // indirect
Expand Down
59 changes: 59 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,24 @@ token:
- endorser2
- endorser2

# replay configures the guard that protects this TMS's endorsement responder from
# re-processing (or concurrently processing) an already-seen proposal. Applies to
# both token-request approval and public-params setup requests.
# If omitted, the guard uses its built-in defaults.
replay:
# backend selects the replay.Guard implementation. Only "memory" is available today.
backend: memory
# window bounds how far a proposal's claimed timestamp may lie from this node's
# current time (in either direction) before it is rejected as out-of-window,
# independently of whether it has been seen before. 0 disables the check.
window: 5m
# ttl is how long a seen proposal is remembered for dedup purposes. It is floored
# to 2*window internally, so a key always survives the window during which it
# could still be replayed.
ttl: 10m
# maxEntries bounds the in-memory dedup cache size (LRU eviction). 0 = unbounded.
maxEntries: 100000

# recovery config controls background re-registration of finality listeners
# for pending transactions that may have lost their listeners due to node restarts,
# network interruptions, or other failures.
Expand Down Expand Up @@ -601,6 +619,47 @@ Default values:
- Decrease `scanInterval` to 2-3s for faster recovery detection


### Optional: token.tms.<name>.services.network.fabric.fsc_endorsement.replay

Configures the replay-detection guard used by the FSC endorsement responder for this TMS
(see [Replay Detection](services/network-fabric.md#replay-detection)). Since FabricX reuses
the same `fsc_endorsement` configuration namespace, this section applies equally to Fabric and
FabricX TMSs. Each TMS gets its own guard instance, built from its own configuration.

If not specified, the default configuration is:

```yaml
token:
tms:
<name>:
services:
network:
fabric:
fsc_endorsement:
replay:
backend: memory
window: 5m
ttl: 10m
maxEntries: 100000
```

Default values:

- backend: `memory` (only backend available today)
- window: 5m (freshness window; 0 disables the check)
- ttl: 10m (dedup retention; floored to `2*window` if set lower)
- maxEntries: 100000 (0 = unbounded)

**Notes:**
- `window` and `ttl` are independent controls: `window` rejects a proposal outright if its
claimed timestamp is too far from this node's clock (in either direction); `ttl` bounds how
long an accepted proposal's key is remembered for deduplication.
- An omitted `replay` key uses `replay.DefaultConfig()` in full, so existing deployments that
predate this option are unaffected.
- A future distributed backend (e.g. Postgres- or Redis-backed, for multi-replica endorsers)
can be added as a new `backend` value without changing this schema.


### Optional: token.storage.tableNames

Globally overrides individual SQL table short codes for all TMS instances on the node.
Expand Down
8 changes: 8 additions & 0 deletions docs/services/network-ethereum.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,14 @@ interface ITokenContractWithEndorsement {

### FSC Endorser Implementation

An Ethereum/EVM endorser should reject a proposal it has already processed before running
any validation, the same way the Fabric FSC responder does. Rather than reimplementing that
check, reuse the driver-agnostic
[`replay.Guard`](../../token/services/network/common/replay/guard.go) (see
[Replay Detection](./network-fabric.md#replay-detection)): build a `replay.Key` from the
Ethereum request's own txID/creator/nonce/timestamp-equivalent fields and call
`Check` immediately after receiving the request, before `Validate`/`ComputeDelta`/`Sign`.

**Endorser Service:**
```go
type EthereumEndorserService struct {
Expand Down
77 changes: 77 additions & 0 deletions docs/services/network-fabric.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,83 @@ writing; the existing ledger setup-key listener (see
[Public Parameters Management](#public-parameters-management)) detects the committed
write and updates the TMS.

### Replay Detection

Both FSC responders — token-request approval and public-params setup — share a single
`ResponderView.Call` entry point
([`fsc/responder.go`](../../token/services/network/fabric/endorsement/fsc/responder.go)).
Immediately after receiving a proposal, and before any expensive validation (creator/MSP
checks, signature verification, or behaviour-specific validation) runs, the responder checks
that the proposal is fresh and whether an equivalent proposal has already been processed:

```mermaid
sequenceDiagram
participant FSC as FSC Endorsement Service
participant Guard as replay.Guard
participant Val as validateProposal / behaviour.validate

FSC->>FSC: receive(proposal)
FSC->>FSC: extract replay.Key (txID, creator, nonce, timestamp)
FSC->>Guard: Check(ctx, key)
Comment thread
AkramBitar marked this conversation as resolved.
alt timestamp outside freshness window
Guard-->>FSC: ErrOutOfWindow
FSC-->>FSC: abort, no validation/endorsement performed
else already seen
Guard-->>FSC: ErrAlreadyProcessed
FSC-->>FSC: abort, no validation/endorsement performed
else first time and fresh
Guard-->>FSC: nil
FSC->>Val: continue normal validation and endorsement
end
```

The replay-detection key
([`replay.Key`](../../token/services/network/common/replay/guard.go)) is derived entirely
from the content of the incoming proposal itself — `TxID`, `Creator`, `Nonce`, and the
proposal's `ChannelHeader.Timestamp` — rather than from anything computed or cached
server-side, so the guard rejects both a literal replay of a previously-seen proposal and a
second, concurrent proposal carrying the same identity.

Before performing the dedup check, the guard also enforces a **freshness window**: the
proposal's claimed `Timestamp` must lie within `Window` of the guard's own current time, in
either direction (`now-Window <= Timestamp <= now+Window`). The window moves continuously
with the node's wall clock. This bounds how far in the past a proposal can be replayed —
closing the gap once the dedup cache itself has forgotten a key that is still technically
replayable — and rejects proposals whose claimed timestamp is skewed too far into the future.
A proposal whose timestamp falls outside the window fails with `fsc.ErrOutOfWindow` (wrapping
`replay.ErrOutOfWindow`) without ever reaching the dedup cache; a proposal that is fresh but
already seen fails with `fsc.ErrAlreadyProcessed` (wrapping `replay.ErrAlreadyProcessed`). In
neither case is the request idempotently replayed from a cached result — the caller must
retry with a fresh proposal.

This guard is implemented as a small, driver-agnostic component under
[`token/services/network/common/replay`](../../token/services/network/common/replay), so
other endorser-style network drivers (e.g. FabricX, and a future Ethereum/EVM driver) can
reuse it instead of duplicating the check:

- `replay.Guard` — the interface (`Check(ctx, key) error`), satisfied by any backend.
- `replay/memory` — the default, in-memory implementation: a freshness-window check followed
by an LRU-backed dedup cache with a configurable TTL and max entry count
(`replay.DefaultConfig()`: a 5-minute window, a 10-minute TTL, 100,000 entries). Entries are
local to a single process — they do not survive a restart and are not shared across
replicas of the same node.
- `replay/factory` — builds a `Guard` from a `replay.Config` (`Backend` + `Window` + `TTL` +
`MaxEntries`); `memory` is the only backend today, but additional backends (e.g. a
Postgres- or Redis-backed guard for multi-replica endorsers) can be added here without
changing any caller. The factory enforces `TTL >= 2*Window`, raising the effective TTL when
necessary, so a dedup entry always survives the entire window during which its key could
still be replayed. Setting `Window` to `0` disables the freshness check entirely, leaving
pure dedup behavior.

The guard is built **per TMS**, not once per node: both the Fabric and FabricX endorsement
loaders construct their own `Guard` inside `loader.load` from that TMS's
`services.network.fabric.fsc_endorsement.replay` configuration (`endorsement.NewReplayGuard`,
shared by both drivers since FabricX reuses the Fabric `fsc_endorsement` namespace), falling
back to `replay.DefaultConfig()` field-for-field when the block is absent. This means each
TMS an endorser serves gets its own dedup cache, sized and tuned independently — see
[Optional: token.tms.\<name\>.services.network.fabric.fsc_endorsement.replay](../configuration.md#optional-tokentmsservicesnetworkfabricfsc_endorsementreplay)
for the full configuration reference.

#### Reachability

`SetupPublicParamsView` is reachable from application code through the same layering
Expand Down
2 changes: 1 addition & 1 deletion docs/services/network-fabricx.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ sequenceDiagram

## FSC Endorsement Service

The FSC Endorsement Service ([`fsc.EndorsementService`](../../token/services/network/fabric/endorsement/fsc/service.go)) manages the endorsement process for FabricX.
The FSC Endorsement Service ([`fsc.EndorsementService`](../../token/services/network/fabric/endorsement/fsc/service.go)) manages the endorsement process for FabricX. This is the same responder implementation used by the traditional Fabric FSC-endorsement path, including its replay-detection guard — see [Replay Detection](./network-fabric.md#replay-detection) for details.

### Endorsement Policies

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/hyperledger-labs/fabric-smart-client v0.15.1
github.com/hyperledger/fabric-chaincode-go/v2 v2.3.0
github.com/hyperledger/fabric-lib-go v1.1.5-0.20260708100132-163bcc919208
Expand Down
1 change: 0 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDe
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
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=
Expand Down
1 change: 1 addition & 0 deletions integration/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/hyperledger-labs/fabric-smart-client/platform/fabric/services/state/cc/query v0.14.2 // indirect
github.com/hyperledger-labs/fabric-smart-client/platform/view/services/comm/host/libp2p v0.14.2 // indirect
Expand Down
46 changes: 46 additions & 0 deletions token/services/network/common/replay/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package replay

import "time"

// Backend identifies which Guard implementation to use.
type Backend string

const (
// BackendMemory selects the in-memory Guard. See replay/memory.
BackendMemory Backend = "memory"
)

// Config is the configuration for a replay Guard.
type Config struct {
// Backend selects the Guard implementation. Defaults to BackendMemory.
Backend Backend `yaml:"backend"`
// Window bounds how far a key's claimed Timestamp may lie from the guard's current time,
// in either direction, before it is rejected with ErrOutOfWindow. The window moves with
// the guard's clock. Window <= 0 disables the freshness check.
Window time.Duration `yaml:"window"`
// TTL is how long a seen key is remembered before it can be forgotten. Only meaningful
// for backends whose entries expire (e.g. BackendMemory). Must be at least 2*Window so an
// entry survives its entire potential-replay lifetime; backends enforce this floor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says backends enforce the floor, but memory.New doesn't, only factory.New clamps it (factory.go:23). Anyone calling memory.New directly, tests or a driver following the ethereum doc, silently gets ttl < 2*window and an entry that can be forgotten while it's still replayable. Either move the clamp into memory.New or reword this to say the factory enforces it.

TTL time.Duration `yaml:"ttl"`
// MaxEntries caps the number of keys remembered at once (0 means unbounded). Only
// meaningful for backends with a bounded size (e.g. BackendMemory).
MaxEntries int `yaml:"maxEntries"`
}

// DefaultConfig returns the configuration used when none is explicitly set: an in-memory
// guard with a 5-minute freshness window, remembering a key for 10 minutes, bounded to
// 100000 entries.
func DefaultConfig() Config {
return Config{
Backend: BackendMemory,
Window: 5 * time.Minute,
TTL: 10 * time.Minute,
MaxEntries: 100_000,
}
}
31 changes: 31 additions & 0 deletions token/services/network/common/replay/factory/factory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

// Package factory builds a replay.Guard from a replay.Config. It is a separate package from
// replay itself so that replay (which defines the Guard interface and Key type) does not need
// to import any concrete implementation, avoiding an import cycle.
package factory

import (
"github.com/LFDT-Panurus/panurus/token/services/network/common/replay"
"github.com/LFDT-Panurus/panurus/token/services/network/common/replay/memory"
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
)

// New builds the Guard selected by cfg.
func New(cfg replay.Config) (replay.Guard, error) {
switch cfg.Backend {
case replay.BackendMemory, "":
ttl := cfg.TTL
if floor := 2 * cfg.Window; ttl < floor {
ttl = floor
}

return memory.New(cfg.Window, ttl, cfg.MaxEntries), nil
default:
return nil, errors.Errorf("unknown replay guard backend: %s", cfg.Backend)
}
}
57 changes: 57 additions & 0 deletions token/services/network/common/replay/factory/factory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package factory_test

import (
"context"
"testing"
"time"

"github.com/LFDT-Panurus/panurus/token/services/network/common/replay"
"github.com/LFDT-Panurus/panurus/token/services/network/common/replay/factory"
"github.com/LFDT-Panurus/panurus/token/services/network/common/replay/memory"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNew_DefaultConfig(t *testing.T) {
g, err := factory.New(replay.DefaultConfig())

require.NoError(t, err)
assert.IsType(t, &memory.Guard{}, g)
}

func TestNew_EmptyBackendDefaultsToMemory(t *testing.T) {
g, err := factory.New(replay.Config{})

require.NoError(t, err)
assert.IsType(t, &memory.Guard{}, g)
}

func TestNew_UnknownBackend(t *testing.T) {
_, err := factory.New(replay.Config{Backend: "unknown"})

require.Error(t, err)
assert.Contains(t, err.Error(), "unknown replay guard backend")
}

func TestNew_TTLFloorDerivedFromWindow(t *testing.T) {
// TTL is shorter than 2*Window: an entry must still be kept for the whole window
// lifecycle, so a key seen just inside the window must not be forgotten before it exits it.
g, err := factory.New(replay.Config{Window: time.Minute, TTL: time.Second, MaxEntries: 0})
require.NoError(t, err)

now := time.Now()
key := replay.Key{TxID: "tx1", Creator: []byte("c"), Nonce: []byte("n"), Timestamp: now}
require.NoError(t, g.Check(context.Background(), key))

time.Sleep(2 * time.Second)

// TTL alone (1s) would have evicted the entry by now; the floor (2*Window = 2m) must not.
err = g.Check(context.Background(), key)
require.ErrorIs(t, err, replay.ErrAlreadyProcessed)
}
Loading
Loading