The Fabric network implementation (fabric.Network) provides integration with Hyperledger Fabric networks using the traditional chaincode-based endorsement model. It leverages the Fabric Smart Client (FSC) to interact with the underlying Hyperledger Fabric network.
The Fabric implementation uses a Token Chaincode deployed on Fabric peers to handle token operations. This chaincode validates token requests, manages token state, and enforces business logic.
graph TB
subgraph "Application Node running FSC/Panurus stack"
App[Application/TTX]
FabricNet[Fabric Network Service]
end
subgraph "Hyperledger Fabric Network"
Peer1[Peer 1<br/>Token Chaincode]
Peer2[Peer 2<br/>Token Chaincode]
Orderer[Ordering Service]
Ledger[Committer]
end
App -->|1. Request Approval| FabricNet
FabricNet -->|2. Endorse Proposal| Peer1
FabricNet -->|2. Endorse Proposal| Peer2
Peer1 -->|3. Endorsement| FabricNet
Peer2 -->|3. Endorsement| FabricNet
FabricNet -->|4. Broadcast Tx| Orderer
Orderer -->|5. Order & Distribute| Ledger
Ledger -->|6. Finality Event| FabricNet
FabricNet -->|7. Notify| App
The Token Chaincode (tcc.TokenChaincode) is a Fabric chaincode that runs on peers and handles all token-related operations.
The chaincode exposes the following functions:
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
invoke |
Process token requests (issue, transfer, redeem) | Token request (transient) | Transaction envelope |
queryPublicParams |
Retrieve public parameters | Public parameters bytes | |
queryTokens |
Query token state | Token IDs | Token data |
areTokensSpent |
Check if tokens are spent | Token IDs, metadata | Boolean array |
queryStates |
Query arbitrary state keys | State keys | State values |
The Token Chaincode must be deployed to the Fabric network before Panurus can operate:
sequenceDiagram
participant Admin as Network Admin
participant Peer as Fabric Peer
participant Orderer as Ordering Service
participant Ledger as Blockchain
Admin->>Peer: Package chaincode
Admin->>Peer: Install chaincode
Admin->>Peer: Approve chaincode definition
Admin->>Orderer: Commit chaincode definition
Orderer->>Ledger: Record chaincode metadata
Ledger-->>Peer: Chaincode ready
Note over Admin,Ledger: Chaincode is now available for invocation
Deployment Steps:
At initialization time, the chaincode loads public parameters and persists them to the setup key on the ledger. The implementation in tcc.TokenChaincode.Init() calls tcc.TokenChaincode.Params(), which resolves the parameters using the following precedence:
PUBLIC_PARAMS_FILE_PATH environment variable is set, tcc.TokenChaincode.ReadParamsFromFile() reads the raw public-parameter bytes from that file and feeds them back as a base64 string.tcc.Params is used. In the source tree this variable is empty by default, but packaging tools replace tcc/params.go with generated content that embeds a base64-encoded blob of the public parameters into the chaincode package itself.This means the token chaincode supports both models:
tcc.ParamsPUBLIC_PARAMS_FILE_PATHtokengen can also generate the token-chaincode package with the public parameters already embedded, by generating a replacement for tcc/params.go from the template in cc.DefaultParams as part of cc.GeneratePackage().
// Simplified initialization flow
func (cc *TokenChaincode) Init(stub shim.ChaincodeStubInterface) *pb.Response {
// Resolve public parameters from file override or built-in Params
ppRaw, err := cc.Params(Params)
// Write the selected parameters to the ledger setup key
w := translator.New(stub.GetTxID(), ...)
w.Write(context.Background(), &SetupAction{SetupParameters: ppRaw})
return shim.Success(nil)
}
Writing the public parameters to the ledger is only part of the setup: to answer invoke and areTokensSpent, the chaincode also needs a validator and a public-parameters manager built from those parameters. These are created lazily, on the first request that needs them, by tcc.TokenChaincode.GetValidator(), which resolves the parameters with the same precedence as Init() and then calls the TokenServicesFactory configured by the chaincode’s main.
Initialization runs at most once successfully:
PUBLIC_PARAMS_FILE_PATH — therefore does not permanently disable the chaincode.GetValidator() never reports success with a nil validator, so a request that reaches validation is always served by an initialized chaincode.
The Fabric implementation supports chaincode-based endorsement through the ChaincodeEndorsementService.
sequenceDiagram
participant App as Application
participant Net as Network Service
participant ES as Endorsement Service
participant FSC as Fabric Smart Client
participant Peer as Fabric Peer
App->>Net: RequestApproval(request)
Net->>ES: Endorse(request, signer, txID)
ES->>FSC: NewEndorseView(namespace, "invoke")
ES->>FSC: WithTransient("token_request", request)
ES->>FSC: WithSignerIdentity(signer)
ES->>FSC: WithTxID(txID)
FSC->>Peer: Send proposal
Peer->>Peer: Execute chaincode
Peer->>Peer: Generate RWSet
Peer->>Peer: Sign endorsement
Peer-->>FSC: Endorsement response
FSC-->>ES: Transaction envelope
ES-->>Net: Endorsed envelope
Net-->>App: Endorsed envelope
The chaincode endorsement follows Fabric’s standard endorsement policies:
Example policy: "OR('Org1MSP.peer', 'Org2MSP.peer')" - requires endorsement from either Org1 or Org2.
As an alternative to chaincode-based endorsement, FSC nodes equipped with a proper
endorsement key can endorse the token chaincode themselves (see
services.network.fabric.fsc_endorsement in Configuration).
The set of endorsers to contact is selected by fsc_endorsement.policy.type:
1outn — contact one random configured endorser.all (default) — contact all configured endorsers.namespace — fetch the namespace’s real endorsement policy via Fabric service
discovery (Channel.Chaincode(namespace).Discover()) and contact a random subset of
the configured endorsers that satisfies it. Discovery already returns the required
MSPs; if none of the configured endorsers can cover them, endorsement fails with an
error rather than falling back to a weaker policy. See
FabricX FSC Endorsement Service for
the equivalent (query-service-based) mechanism on FabricX.Besides endorsing token requests, the same FSC endorsement machinery can be used to
submit new or updated public parameters (PP) for a namespace, as an alternative to
setting them through the chaincode Init lifecycle callback. A second
initiator/responder pair handles this:
SetupPublicParamsView (initiator) builds an endorsement proposal carrying the raw
PP bytes and submits it for endorsement, exactly like RequestApprovalView does for
token requests.SetupPublicParamsResponderView (responder) receives the proposal, validates it, and
writes the PP into the RWSet via the existing translator.SetupAction mechanism —
the same setup key/hash that the chaincode Init path writes.The two initiator/responder pairs are distinguished by chaincode function name and
transient key: the token-request flow uses the invoke function, while the PP flow
uses a dedicated setup function and carries the raw parameters under a dedicated
public_params transient key (in addition to the tmsID key used by both). On
re-setup of a namespace that already has a TMS, a third transient key,
public_params_sig, carries a detached signature (JSON-encoded signer identity plus
signature bytes) authorizing the change — see below.
Both responders share a common receive step that only checks that the proposal’s
tmsID transient carries a non-empty network, channel, and namespace — it does not
look up the TMS itself. Each responder looks up the TMS for that tmsID on its own,
at validation time, and decides for itself whether an absent TMS is a problem:
invoke) requires an existing TMS; if none is found,
validation fails.setup) tolerates a missing TMS, since the namespace may be
going through its first-time initialization. Any other lookup error (i.e. anything
other than “TMS not found”) still fails validation.Endorsing the proposal (common to both responders) needs the local endorser identity,
resolved from fsc_endorsement.id in the namespace’s configuration. This is resolved
directly from configuration rather than through a fully-built TMS, so it also works
during first-time PP setup, before any TMS exists for the namespace.
The responder enforces the following before writing:
tmsID carried by the
proposal.PublicParametersFromBytes) and pass
PublicParameters.Validate().public_params_sig transient: a detached signature over
the raw PP bytes, produced by one of the current PP’s issuers;Deserializer
(SigService().IssuerVerifier).Driver name, version, and CertificationDriver() are all allowed to change freely
on re-setup — the only gate is the current-issuer signature above, there is no
equality check against the previous PP. There is likewise no monotonicity or
replay protection beyond what Fabric’s own transaction anti-replay already
provides.
The signature itself is produced entirely on the initiator side, without changing the
public SetupPublicParams API: fsc.EndorsementService resolves the current TMS via
its TokenManagementSystemProvider, iterates the current PP’s issuers, and signs the
raw PP bytes with the first one it holds a local wallet/signer for. On first-time setup
(no current TMS) it submits no signature, matching the responder’s tolerance above.
There is no separate “init” vs. “update” API: the write is an unconditional overwrite
of the setup key, mirroring the semantics of the chaincode Init path. Endorser
selection reuses the same fsc_endorsement.policy.type mechanism described above
(1outn/all/namespace) — there is no separate policy configuration for PP setup.
As with token requests, the responder does not refresh the local TMS directly after writing; the existing ledger setup-key listener (see Public Parameters Management) detects the committed write and updates the TMS.
SetupPublicParamsView is reachable from application code through the same layering
as RequestApproval, but keyed by TMSID rather than *token.ManagementService — a
namespace has no TMS yet before its first PP setup:
network.Network.SetupPublicParams(tmsID, ppRaw, signer, txID)
-> driver.Network.SetupPublicParams
-> fabric.Network.SetupPublicParams
-> endorsement.Service.SetupPublicParams
-> fsc.EndorsementService.SetupPublicParams
-> NewSetupPublicParamsView
Chaincode-based endorsement (ChaincodeEndorsementService) does not support this
call and returns an error — for that endorsement mode, PP setup/update remains
exclusively through the chaincode Init lifecycle callback described above.
The Fabric implementation supports two modes for monitoring transaction finality:
Uses a block delivery stream from the peer for real-time finality tracking:
sequenceDiagram
participant App as Application
participant Net as Network Service
participant FM as Finality Manager
participant Peer as Fabric Peer
participant Proc as Block Processor
App->>Net: AddFinalityListener(txID, listener)
Net->>FM: Register listener
loop Block Delivery Stream
Peer->>FM: Deliver block
FM->>Proc: Process block (parallel)
Proc->>Proc: Extract transactions
Proc->>Proc: Check validation codes
Proc->>Proc: Match registered listeners
Proc->>Net: Notify listener(txID, status)
Net->>App: OnStatus(txID, VALID/INVALID)
end
Features:
The scan resumes at the peer’s current ledger height, read via GetLedgerInfo
(delivery.go). Because that
one RPC decides the starting block, a failure is retried with an exponentially growing
delay — 7 attempts spanning ~31.5s by default, aborting early if the context is cancelled.
Both are configurable via token.finality.delivery.ledgerInfoAttempts and
ledgerInfoRetryDelay (see Finality Configuration).
That budget is deliberately long. Nothing retries ScanBlock: FSC’s
events.ListenerManager calls it once from a goroutine that only logs the result, so an
error escaping the retry loop leaves the channel with no block-based finality until the
process restarts. The retries therefore have to outlast a peer restart, not merely a
dropped packet.
If the height is still unavailable after the last attempt, ScanBlock returns the error
rather than defaulting to block 0. Starting at genesis would rescan the entire chain and
replay finality notifications for every historical transaction, while the caller would have
no way to tell a transient RPC failure from a genuinely fresh chain. Block 0 is used only
when no ledger is configured at all.
The doubling has a ceiling of 30s, so raising ledgerInfoAttempts lengthens the budget
without letting a single pause grow without bound. A ledgerInfoRetryDelay larger than the
ceiling is honoured as configured — the cap limits growth, it does not shorten the delay you
asked for. The backoff itself is utils.RetryRunner, shared with the rest of the SDK rather
than reimplemented here.
The returned error is classifiable with errors.Is, so a caller does not have to match on
its message. ErrLedgerHeightUnavailable is the single test for “the starting block could
not be resolved, so no scan started” — it accompanies every such failure, including a
cancelled one:
| Sentinel | Meaning |
|---|---|
finality.ErrLedgerHeightUnavailable |
the height could not be read, so no scan started |
finality.ErrNoLedgerInfo |
some attempt saw the ledger return neither info nor an error — a driver contract violation |
context.Canceled / context.DeadlineExceeded |
a wait between attempts was cut short, or the scan itself was cancelled |
Every attempt’s failure is reported, so ErrNoLedgerInfo is present whenever the contract
was violated at least once, even intermittently. The context error is not a discriminator on
its own: the same context governs the scan, so it does not say which phase ended — pair it
with ErrLedgerHeightUnavailable to tell a cancelled height read from a cancelled scan.
The underlying ledger errors are preserved in every case. An error that does not match
ErrLedgerHeightUnavailable comes from the block scan itself rather than from resolving the
starting block.
There is no in-tree caller that inspects these sentinels yet — today the sole consumer logs the error and stops. Classifying the failure is what a future caller needs to react (fall back to query-based finality, retry, or restart the manager) rather than a description of current behaviour.
Uses asynchronous event notifications from the FSC layer:
sequenceDiagram
participant App as Application
participant Net as Network Service
participant FM as Finality Manager
participant FSC as Fabric Smart Client
App->>Net: AddFinalityListener(txID, listener)
Net->>FM: Register listener
FSC->>FSC: Monitor ledger events
FSC->>FM: Transaction event(txID, status)
FM->>Net: Notify listener
Net->>App: OnStatus(txID, VALID/INVALID)
The Fabric implementation monitors the ledger for public parameters updates:
sequenceDiagram
participant Net as Network Service
participant SL as Setup Listener
participant Ledger as Blockchain
participant TMS as TMS Provider
participant DB as Tokens Database
Note over Net,DB: Initialization
Net->>SL: Register setup listener
SL->>Ledger: Monitor setup key
Note over Net,DB: Update Detection
Ledger->>SL: Setup key modified
SL->>SL: Fetch new parameters
SL->>TMS: Update(new params)
SL->>DB: Persist(new params)
Note over Net,DB: SDK synchronized with new parameters
The setup listener watches for changes to a specific ledger key that stores public parameters. This mechanism is based on delivery as the finality path, because setup-key updates are detected from committed ledger events delivered by the peer:
The same delivery-based mechanism is reused by the lookup service to detect transfer action metadata writes: it derives the transfer action metadata key prefix from KeyTranslator.TransferActionMetadataKeyPrefix and prefix-matches rwset writes against it, without needing to know each write’s specific subkey ahead of time.
When resolving a batch of keys, the lookup service groups them by namespace and issues one
QueryStates chaincode call per namespace
(deliveryqs.go). Each namespace is
resolved independently: if its query cannot be built, does not reach the chaincode, or returns a
response that cannot be decoded, only that namespace falls back to the block scan. The keys of the
other namespaces in the same batch are still resolved and delivered.
The block scan itself is the fallback shared by the lookup service and the
finality service: it rewinds
NumberPastBlocks blocks behind the last known block and scans forward from there. The starting
block is computed by blockscan.StartingBlock,
which clamps the result to FirstBlock so that a chain shorter than the rewind window is scanned
from its first block rather than from an underflowed block number.
The Fabric implementation provides efficient state querying through the chaincode:
sequenceDiagram
participant App as Application
participant Net as Network Service
participant Ledger as Ledger Service
participant CC as Token Chaincode
participant State as World State
App->>Net: QueryTokens(tokenIDs)
Net->>Ledger: GetStates(namespace, keys)
Ledger->>CC: Query("queryTokens", tokenIDs)
CC->>State: GetState(tokenID)
State-->>CC: Token data
CC-->>Ledger: Token data array
Ledger-->>Net: Token data
Net-->>App: Token data
sequenceDiagram
participant App as Application
participant Net as Network Service
participant CC as Token Chaincode
participant State as World State
App->>Net: AreTokensSpent(tokenIDs)
Net->>CC: Query("areTokensSpent", tokenIDs)
CC->>State: GetState(spentKey)
State-->>CC: Spent markers
CC->>CC: Check each token
CC-->>Net: Boolean array
Net-->>App: [true, false, true, ...]
token:
enabled: true
tms:
my-fabric-tms:
network: fabric-network-name # Matches fsc.networks configuration
channel: my-channel
namespace: my-chaincode-id # Token chaincode name
token:
finality:
type: delivery # "delivery" or "notification"
committer:
maxRetries: 3
retryWaitDuration: 5s
delivery:
mapperParallelism: 10 # Parallel transaction mappers
blockProcessParallelism: 10 # Parallel block processors
lruSize: 30 # Cache size for recent transactions
listenerTimeout: 10s # Timeout for listener notifications
ledgerInfoAttempts: 7 # Attempts at reading the starting ledger height
ledgerInfoRetryDelay: 500ms # First retry pause; doubles each attempt (~31.5s total)
ledgerInfoAttempts and ledgerInfoRetryDelay bound the ledger-height read that decides
where a block scan starts — see Choosing the Starting Block.
Non-positive values are ignored in favour of the defaults: zero attempts would refuse every
scan, and a non-positive delay would busy loop.
# Chaincode-based endorsement (default)
# No additional configuration needed - uses Fabric's endorsement policies
fabric.Network)
fabric.ledger)
endorsement.ChaincodeEndorsementService)
finality.ListenerManager)
tcc.TokenChaincode)
// Fabric uses SHA256(nonce || creator) for transaction IDs
func (n *Network) ComputeTxID(id *driver.TxID) string {
temp := &fabric.TxID{
Nonce: id.Nonce,
Creator: id.Creator,
}
return n.n.TransactionManager().ComputeTxID(temp)
}