The Token Transaction (TTX) Service is the primary orchestration component of Panurus. it provides a high-level API and a set of Fabric Smart Client (FSC) Views to help developers assemble, sign, and commit token transactions in a backend-agnostic manner.
The TTX service is designed with a dependency injection pattern (located in token/services/ttx/dep), which decouples the transaction orchestration logic from the underlying infrastructure providers like the Network Service, TMS Provider, and Storage Service.
The lifecycle of a token transaction typically involves the following stages, coordinated by the TTX service:
sequenceDiagram
autonumber
box darkgreen Panurus Stack
participant Initiator
participant Recipient
participant Auditor
participant Network as Network Service
end
participant Ledger as DLT / Ledger
Note over Initiator: 1. Request Identities (with nonce/signature attestation)
Initiator->>+Recipient: RequestRecipientIdentityView (includes Nonce)
Recipient-->>-Initiator: RecipientResponse (Identity + Audit Info + Signature)
Note over Initiator: 2. Assemble Request
Initiator->>Initiator: Issue / Transfer / Redeem operations
Note over Initiator: 3. Collect Endorsements
Initiator->>Initiator: Sign locally
Initiator->>+Recipient: Request Signatures (for spent tokens)
Recipient-->>-Initiator: Signature
Initiator->>+Auditor: AuditApproveView
Auditor-->>-Initiator: Auditor Signature
Initiator->>+Network: RequestApproval (DLT Endorsement)
Network-->>-Initiator: Endorsed Envelope
Note over Initiator: 4. Distribution & Ordering
Initiator->>Recipient: Distribute Transaction Metadata
Initiator->>+Network: Broadcast Transaction
Network->>+Ledger: Submit to Orderer
Ledger-->>-Network: Transaction Committed
Note over Initiator: 5. Finality Tracking
Initiator->>+Network: Listen for Finality
Network-->>-Initiator: Notify Finality
Transactions are instantiated via the ttx.NewTransaction function. A transaction is anchored to a specific Token Management System (TMS) ID, which defines the network, channel, and namespace for the transaction.
When a transaction is created, it:
token.Request via the TMS.TTX responder views cross a network trust boundary. A remote initiator can control envelope bytes, transaction encodings, TMS and wallet identifiers, recipient and composite-identity structures, signature requests, spend requests, and the timing or omission of protocol messages. Responders must treat all of these inputs as hostile.
The assets protected at this boundary are:
Responder processing follows four security requirements:
TMS implementations, token drivers, local wallets, and configured infrastructure services are trusted. Application code remains responsible for business-policy decisions, such as confirming that a multisig or policy spend transaction consumes exactly the token named in the earlier request. Applications also control which callers can invoke responder views and which local wallets those callers may select.
The complete boundary inventory and security goals are in the TTX Responder Threat Model.
To issue or transfer tokens, the initiator must acquire the recipient’s identity. The TTX service provides interactive protocols for this purpose.
The RequestRecipientIdentityView allows an initiator to request a fresh recipient identity from a counterparty. This process ensures that:
IdentityInfo stores to map the new identity to the correct FSC node.recipients.go)The recipient identity protocols are implemented in token/services/ttx/recipients.go. The diagrams below document the on-wire messages exchanged by the initiator and responder views.
RecipientData can carry Identity, AuditInfo, TokenMetadata, and TokenMetadataAuditInfo.
Wire messages use JSON sessions (token/services/utils/json/session); the diagrams name the Go types being sent or received.
Response paths. In RespondRequestRecipientIdentityView, after the wallet lookup:
recipientRequest.RecipientData != nil, the responder checks OwnerWallet.Contains for RecipientData.Identity, then sends a slim acknowledgement (RecipientResponse with no RecipientData, only a Signature) back on the session (echo path). The initiator already holds the full RecipientData and uses its own copy.recipientRequest.RecipientData == nil, the responder calls OwnerWallet.GetRecipientData and sends a full RecipientResponse carrying the wallet-produced RecipientData plus a Signature (fresh path).Nonce / Signature Binding. Every RecipientRequest (and ExchangeRecipientRequest) carries a cryptographic nonce (NonceSize bytes) generated by the initiator. The responder signs an attestation message — a DER-encoded (encoding/asn1) structure that captures every field of the received request (TMSID, wallet id, identity, multisig flag, policy, nonce) together with the session id and the context id — using the private key corresponding to the returned identity (obtained via tms.SigService().GetSigner). The initiator rebuilds the same structure and verifies the signature with tms.SigService().OwnerVerifier before registering the identity. The exchange flow is mutual: ExchangeRecipientRequest.Signature proves that the initiator owns the recipient identity it asks the responder to register and bind. The session id and context id are propagated in the message header, so both parties reconstruct identical bytes. ASN.1’s tag-length-value framing keeps field boundaries explicit, removing the concatenation ambiguity a flat nonce/identity message would allow. This binds the attestation to one specific request, session, and context, preventing identity-spoofing and replay where a compromised session substitutes a different party’s identity bytes.
Multisig and policy follow-ups. When a composite identity was requested, the initiator sends MultisigRecipientData or PolicyRecipientData after the first exchange. Before changing local state, the responder checks that component identities, audit information, nodes, and recipients have equal cardinality; that ordered recipients match the composite components; that the responder’s attested identity is included; and that a policy identity matches the policy requested in phase one.
RequestRecipientIdentityView / RespondRequestRecipientIdentityViewsequenceDiagram
autonumber
participant I as Initiator
participant R as Responder
rect rgba(230, 230, 250, 0.35)
Note over I,R: Phase 1 - Identity request (with nonce)
I->>I: nonce = GetRandomNonce()
I->>R: RecipientRequest{TMSID, WalletID, RecipientData?, MultiSig, Policy, Nonce}
end
rect rgba(255, 245, 238, 0.5)
Note over R: Phase 2 - Responder decision, attestation, and reply
R->>R: Reject if Nonce is empty
R->>R: msg = asn1(request fields + session id + context id + identity)
alt RecipientRequest.RecipientData != nil (echo path)
R->>R: Verify wallet contains RecipientData.Identity
R->>R: sig = Sign(msg)
R->>R: endpoint.Bind(context.Me, identity)
R-->>I: RecipientResponse{Signature: sig} (slim ack, no RecipientData)
else RecipientRequest.RecipientData == nil (fresh path)
R->>R: recipientData = wallet.GetRecipientData()
R->>R: sig = Sign(msg with recipientData.Identity)
R->>R: endpoint.Bind(context.Me, recipientData.Identity)
R-->>I: RecipientResponse{RecipientData, Signature: sig}
end
end
rect rgba(240, 255, 240, 0.45)
Note over I,R: Phase 3 - Initiator verification, registration, bindings
I->>I: Determine recipientData (own copy on echo, response on fresh)
I->>I: msg = asn1(request fields + session id + context id + recipientData.Identity)
I->>I: verifier = OwnerVerifier(recipientData.Identity)
I->>I: verifier.Verify(msg, resp.Signature)
I->>I: RegisterRecipientIdentity(recipientData)
I->>I: endpoint.Bind(requested FSC identity, recipientData.Identity)
end
rect rgba(245, 245, 245, 0.55)
Note over I,R: Optional multisig extension
opt MultiSig == true
I-->>R: MultisigRecipientData{RecipientData, Nodes, Recipients}
R->>R: RegisterRecipientIdentity(multisig data)
R->>R: endpoint.Bind(each Node -> Recipient)
end
end
ExchangeRecipientIdentitiesView / RespondExchangeRecipientIdentitiesViewsequenceDiagram
autonumber
participant I as Initiator
participant R as Responder
rect rgba(230, 230, 250, 0.35)
Note over I,R: Phase 1 - Exchange request (with nonce)
I->>I: nonce = GetRandomNonce()
I->>I: initiatorSig = Sign(attestation for RecipientData(local))
I->>R: ExchangeRecipientRequest{TMSID, WalletID, RecipientData(local), Nonce, Signature: initiatorSig}
end
rect rgba(255, 245, 238, 0.5)
Note over R: Phase 2 - Responder processing with attestation
R->>R: Reject if Nonce, RecipientData, or initiator Signature is empty
R->>R: Verify initiator attestation before state mutation or disclosure
R->>R: RegisterRecipientIdentity(request.RecipientData)
R->>R: recipientData = wallet.GetRecipientData()
R->>R: msg = asn1(request fields + session id + context id + recipientData.Identity)
R->>R: sig = Sign(msg)
R->>R: endpoint.Bind(context.Me, recipientData.Identity)
R->>R: endpoint.Bind(session caller, request.RecipientData.Identity)
R-->>I: ExchangeRecipientResponse{RecipientData, Signature: sig}
end
rect rgba(240, 255, 240, 0.45)
Note over I,R: Phase 3 - Initiator verification, registration, bindings
I->>I: msg = asn1(request fields + session id + context id + resp.RecipientData.Identity)
I->>I: verifier = OwnerVerifier(resp.RecipientData.Identity)
I->>I: verifier.Verify(msg, resp.Signature)
I->>I: RegisterRecipientIdentity(resp.RecipientData)
I->>I: endpoint.Bind(other FSC identity, resp.RecipientData.Identity)
end
The TTX service supports PolicyIdentity owners: tokens whose spending requires satisfying a boolean expression over a set of component identities. This enables richer access-control than simple multisig (M-of-N) — for example, an OR clause where any single co-owner may spend unilaterally, or complex nested expressions.
Call RequestPolicyIdentity (in token/services/ttx/recipients.go) to negotiate a composite identity from all co-owners before building the transfer:
recipient, err := bptx.RequestRecipientIdentity(ctx, "$0 OR $1",
[]view.Identity{bobFSCIdentity, charlieFSCIdentity},
token.WithTMSIDPointer(tmsID),
)
Each co-owner’s node responds with its component identity; Panurus assembles the PolicyIdentity envelope automatically.
| Expression | Meaning |
|---|---|
$0 OR $1 |
Either component 0 or component 1 can spend alone. |
$0 AND $1 |
Both component 0 and component 1 must sign. |
($0 OR $1) AND $2 |
One of the first two parties plus party 2 must sign. |
$N is a zero-based index into the ordered component identity list supplied when creating the token.
For an OR policy the initiator alone can satisfy the policy. Pass WithPolicySigners to restrict signature collection to only the signing party’s slot; the remaining slots are left nil (which is valid for OR branches):
_, err = context.RunView(ttx.NewCollectEndorsementsView(tx,
ttx.WithPolicySigners(myComponentIdentity),
))
For an AND policy all co-owners must endorse. Use RequestSpendView (in token/services/ttx/boolpolicy/spend.go) to notify co-owners before assembling the transaction, then collect endorsements from all components without restriction:
_, err = context.RunView(bptx.NewRequestSpendView(unspentToken, serviceOpts...))
// ... build tx ...
_, err = context.RunView(ttx.NewCollectEndorsementsView(tx))
Co-owners run ReceiveSpendTxView (via ReceiveSpendTx) on their side, which ACKs the spend request and returns the assembled transaction without endorsing it. The application then inspects the transaction (e.g. confirming it consumes the expected token and does not include other tokens owned by this node) and explicitly calls ttx.NewEndorseView(tx) to sign once those checks pass.
The same coordination protocol is implemented in token/services/ttx/multisig/spend.go (for AND multisig) and in token/services/ttx/boolpolicy/spend.go (for AND policies). Both follow the shape below; the responder must verify that the assembled transaction actually consumes the token referenced by the SpendRequest it approved.
sequenceDiagram
autonumber
participant I as Initiator (RequestSpendView)
participant R as Co-owner (ReceiveSpendTxView)
participant App as Co-owner application code
rect rgba(230, 230, 250, 0.35)
Note over I,R: Phase 1 - Spend approval request
I->>R: SpendRequest{Token: UnspentToken to spend}
R->>R: ReceiveSpendRequest, decide to approve
R-->>I: SpendResponse{}
end
rect rgba(255, 245, 238, 0.5)
Note over I,R: Phase 2 - Transaction assembly and delivery
I->>I: Assemble transaction consuming SpendRequest.Token
I->>R: Transaction (via ttx.ReceiveTransaction)
R-->>App: tx (returned by ReceiveSpendTxView)
end
rect rgba(240, 255, 240, 0.45)
Note over App: Phase 3 - Business-logic checks (application owns these)
App->>App: Inspect tx.Request().Inputs(ctx) etc.
alt checks pass
App->>R: context.RunView(ttx.NewEndorseView(tx))
R-->>I: Signed transaction
else checks fail
App-->>I: Abort (no signature produced)
end
end
The split between Phase 2 (library receives the tx) and Phase 3 (application inspects + endorses) is deliberate: the library does not assume a single check policy. Two checks worth running in most deployments — and easy to express with tx.Request().Inputs(ctx) — are (a) the tx consumes the token named in the SpendRequest, and (b) the tx does not consume any other token owned by this node (see extractRequiredSigners in endorse.go for the ownership-check pattern). Without these, a co-owner who reviews and approves a spend for token T_a could be made to sign a transaction consuming a different token T_b co-owned by the same group.
The boolpolicy.OwnerWallet (in token/services/ttx/boolpolicy/wallet.go) wraps a standard owner wallet and filters the token list to policy-type tokens. VerifyApprover can be used to assert that a given identity is one of the named component identities before allowing a spend.
The EscrowAuth struct (in token/services/ttx/boolpolicy/auth.go) implements the Authorization interface: IsMine returns true if any component identity of the policy token belongs to one of the node’s owner wallets.
Every interactive protocol message in ttx is wrapped in a versioned envelope defined in token/services/utils/json/session/envelope.go. The envelope is what each initiator/responder pair actually exchanges; the per-flow payload structs ride inside the Body field.
type Envelope struct {
Version uint32 `json:"v"` // monotonic protocol version (mandatory)
Type string `json:"t"` // message-type discriminator (mandatory)
Body json.RawMessage `json:"b"` // the actual payload
}
Views send with SendTyped(s, ctx, payload, TypeXxx) / SendEnvelopeOnSession(...) and receive with ReceiveTyped(s, TypeXxx, &dst) (and the WithTimeout variants). These helpers handle envelope wrapping, version/type validation, and metrics in one call so view code stays focused on the payload.
The message-type discriminators live with the service that uses them — the ttx constants in token/services/ttx/protocol_messages.go, and the HTLC interop one in token/services/interop/htlc/distribute.go — not in the generic session package.
| Constant | Value | Used by |
|---|---|---|
TypeRecipientRequest / TypeRecipientResponse |
recipient_req / recipient_resp |
recipients.go request flow |
TypeExchangeRecipientRequest / TypeExchangeRecipientResp |
exchange_req / exchange_resp |
recipients.go exchange flow |
TypeMultisigRecipientData / TypePolicyRecipientData |
multisig_data / policy_data |
recipient follow-ups for multisig / policy identities |
TypeWithdrawalRequest / TypeWithdrawalChallenge / TypeWithdrawalResponse |
withdrawal_req / withdrawal_challenge / withdrawal_resp |
withdrawal.go |
TypeUpgradeAgreement / TypeUpgradeRequest |
upgrade_agree / upgrade_req |
upgrade.go |
TypeSpendRequest / TypeSpendResponse |
spend_req / spend_resp |
multisig/spend.go, boolpolicy/spend.go |
TypeSignatureRequest / TypeSignature |
sig_req / signature |
collectendorsements.go, endorse.go, accept.go, auditor.go |
TypeTransaction / TypeTransactionResponse |
transaction / tx_resp |
tx distribution in collectendorsements.go, auditor.go, collectactions.go, receivetx.go |
TypeActions / TypeActionTransfer |
actions / action_transfer |
collectactions.go |
TypeHTLCTerms (htlc pkg) |
htlc_terms |
interop/htlc/distribute.go |
Two reusable payload structs back the byte-oriented flows: TransactionPayload{Raw []byte} carries a serialized transaction, and SignaturePayload{Signature []byte} carries a signature.
Receivers reject any envelope whose Version differs from CurrentVersion, or whose Type is missing or does not match the expected type — there is no silent fallback to a legacy shape. Failures surface as the sentinel errors in envelope.go:
ErrMissingVersion — Version is zero / absentErrVersionMismatch (also returned as *VersionError with Expected/Received) — version differs from CurrentVersionErrTypeMismatch — received Type ≠ expectedErrInvalidEnvelope — payload did not parse as an envelopeAll satisfy errors.Is. VersionCompatibility / IsCompatible(local, remote) declare which versions interoperate (v1 ↔ v1 only).
EnvelopeMetrics (in metrics.go) records per-type sent/received counters, an error counter, and a body-size histogram. It is registered once per process from the metrics provider obtained via FSC platform/view/services/metrics#GetProvider, resolved lazily by the session constructors (JSON, NewFromSession, …) and read by the typed send/receive helpers. A node with no metrics provider simply runs with metrics disabled.
The withdrawal protocol (withdrawal.go) lets a wallet ask an issuer to mint tokens to a freshly generated recipient identity. It uses a three-message challenge-response so that freshness is controlled by the issuer — the issuer samples the nonce, which prevents an attacker from pre-computing a valid (nonce, signature) pair and replaying it across sessions.
sequenceDiagram
autonumber
participant I as Initiator (RequestWithdrawalView)
participant Iss as Issuer (ReceiveWithdrawalRequestView)
rect rgba(230, 230, 250, 0.35)
Note over I,Iss: Phase 1 - Request
I->>I: Resolve local wallet (r.Wallet)
I->>I: If caller-supplied RecipientData: w.RegisterRecipient(RecipientData) else w.GetRecipientData()
I->>Iss: Envelope{t:"withdrawal_req", b:WithdrawalRequest{TMSID, RecipientData, TokenType, Amount, NotAnonymous}}
end
rect rgba(255, 245, 238, 0.5)
Note over Iss: Phase 2 - Challenge
Iss->>Iss: nonce = GetRandomNonce()
Iss-->>I: Envelope{t:"withdrawal_challenge", b:WithdrawalChallenge{Nonce}}
end
rect rgba(240, 255, 240, 0.45)
Note over I: Phase 3 - Response
I->>I: msg = asn1(TMSID + nil walletID + RecipientData.Identity + nonce + session id + context id)
I->>I: sig = Sign(msg)
I->>Iss: Envelope{t:"withdrawal_resp", b:WithdrawalResponse{Signature: sig}}
end
rect rgba(245, 245, 245, 0.55)
Note over Iss: Phase 4 - Verification and registration
Iss->>Iss: msg = asn1(same fields, using issuer-held nonce)
Iss->>Iss: verifier.Verify(msg, resp.Signature)
Iss->>Iss: RegisterRecipientIdentity(request.RecipientData)
Iss->>Iss: endpoint.Bind(caller -> RecipientData.Identity)
Note over I,Iss: Session returned to caller for the issuance/endorsement flow
end
The attestation message is the same DER-encoded (encoding/asn1) structure used by the recipient-identity protocols, with walletID fixed to nil — the issuer does not need to know the requester’s wallet identifier.
A caller can drive the withdrawal for a recipient identity it generated outside the local
wallet (an “external wallet”) by passing RecipientData directly to
RequestWithdrawalForRecipient. Before this data is sent to the issuer,
RequestWithdrawalView.getRecipientIdentity resolves the caller’s local r.Wallet and calls
OwnerWallet.RegisterRecipient(ctx, RecipientData) on it. For an AnonymousOwnerWallet this
runs Deserializer.MatchIdentity(Identity, AuditInfo) before registering and binding the
identity, so a caller-supplied Identity/AuditInfo pair that doesn’t match is rejected with
an error and the withdrawal request is never sent. For a LongTermOwnerWallet,
RegisterRecipient is currently a no-op, so this check does not add protection when the
caller’s local wallet is long-term-identity-backed rather than anonymous.
The upgrade protocol (upgrade.go) exchanges old-format tokens for new-format ones over two round-trips: an agreement that establishes a fresh challenge, then a request carrying the proof.
sequenceDiagram
autonumber
participant I as Initiator (UpgradeTokensInitiatorView)
participant Iss as Issuer (UpgradeTokensResponderView)
I->>Iss: Envelope{t:"upgrade_agree", b:UpgradeTokensAgreement{}}
Iss->>Iss: NewUpgradeChallenge, set TMSID
Iss-->>I: Envelope{t:"upgrade_agree", b:UpgradeTokensAgreement{Challenge, TMSID}}
I->>I: GenUpgradeProof(challenge, tokens), then resolve recipient identity
I->>Iss: Envelope{t:"upgrade_req", b:UpgradeTokensRequest{ID, TMSID, RecipientData, Tokens, Proof}}
Iss->>Iss: Verify request.ID == challenge, verify TMS matches
Note over I,Iss: Responder continues with issuance, session returned to caller
The responder checks request.ID byte-for-byte against the challenge it issued, so a stale or substituted request is rejected before any proof verification.
CollectEndorsementsView (collectendorsements.go) gathers the signatures that make a transaction valid, then distributes the assembled transaction. Two message exchanges are involved, both enveloped:
sequenceDiagram
autonumber
participant I as Initiator (CollectEndorsementsView)
participant P as Party (EndorseView)
rect rgba(230, 230, 250, 0.35)
Note over I,P: Signature collection (per required signer)
I->>P: Envelope{t:"sig_req", b:SignatureRequest{TX, Signer}}
P->>P: Verify expected identity, sign
P-->>I: Envelope{t:"signature", b:SignaturePayload{Signature}}
end
rect rgba(240, 255, 240, 0.45)
Note over I,P: Transaction distribution (per recipient)
I->>P: Envelope{t:"transaction", b:TransactionPayload{Raw}}
P->>P: ReceiveTransaction, AcceptView checks + signs ack
P-->>I: Envelope{t:"signature", b:SignaturePayload{Signature}}
end
EndorseView (endorse.go) is the responder for the signature-request leg; before acknowledging the final distribution it requires the token actions, TMS identity, network transaction creator/nonce, signer, and transient data to match the transaction it reviewed. AcceptView (accept.go) responds to the transaction-distribution leg with a signed acknowledgement. ReceiveTransactionView (receivetx.go) unwraps the envelope and accepts TypeTransaction, TypeTransactionResponse, or TypeSignatureRequest. Transaction and transient ASN.1 decoders reject trailing data, duplicate transient keys, and key/value cardinality mismatches.
AuditingViewInitiator (auditor.go) sends the assembled transaction to the auditor and waits for the auditor’s signature; AuditApproveView audits, signs, and returns it.
sequenceDiagram
autonumber
participant I as Initiator (AuditingViewInitiator)
participant A as Auditor (AuditApproveView)
I->>A: Envelope{t:"transaction", b:TransactionPayload{Raw}}
A->>A: ReceiveTransaction, Append audit records, sign
A-->>I: Envelope{t:"signature", b:SignaturePayload{Signature}}
I->>I: Verify auditor signature
collectActionsView (collectactions.go) drives a transfer where the action originates with a remote party: it ships the transaction and the requested actions, and receives the assembled transaction back.
sequenceDiagram
autonumber
participant I as Initiator (collectActionsView)
participant R as Responder (receiveActionsView / collectActionsResponderView)
I->>R: Envelope{t:"transaction", b:TransactionPayload{Raw}}
I->>R: Envelope{t:"actions", b:Actions}
I->>R: Envelope{t:"action_transfer", b:ActionTransfer}
R->>R: Receive transaction, actions, action, then assemble
R-->>I: Envelope{t:"tx_resp", b:TransactionPayload{Raw}}
On each response, the initiator merges the returned payload into its transaction. The returned token request must be a superset-extension of the current one (its actions must start with exactly the actions already present, in the same order, which is what this round-trip protocol produces); a violation is treated as an error rather than silently accepted. Transient entries from the response are merged in; if a key is already set locally with a different value, the merge fails rather than silently picking one side.
The TTX service supports three primary operations through the TokenRequest API:
Allows authorized issuers to create new tokens. The service:
Enables the transfer of token ownership. The service:
TokenLocks table to prevent double-spending.A specialized transfer where the recipient is “hidden” or “empty,” effectively removing the tokens from circulation on the ledger.
Redeem supports an enhanced flow where an issuer signature is required as part of transfer validation:
tx.Redeem(...).ttx.WithFSCIssuerIdentity(...) so the initiator can contact the issuer for endorsement.ttx.WithIssuerPublicParamsPublicKey(...) to pin which issuer public-parameters signing key must authorize the redeem.CollectEndorsementsView to collect owner, auditor (if configured), and issuer signatures.On commit, a redeem output (empty owner) is attributed to the issuer that signed the transfer action. The issuer node stores the redeem as its own record (flagged Redeemed) so it counts toward that issuer wallet’s RedeemedBalance and net Balance; redeems that cannot be attributed to a locally-known issuer are discarded rather than stored. See Issuer Balance.
The CollectEndorsementsView is responsible for gathering all signatures required to make a transaction valid:
AuditApproveView.external.go)When the signing key for a wallet does not live on the node, ttx.WithExternalWalletSigner(walletID, ews)
registers an ExternalWalletSigner (Sign(party, message) plus Done()) that
CollectEndorsementsView calls instead of the local signature service. external.go provides an
implementation of that interface on top of an FSC stream, so the wallet holder can be the
application that invoked the view over StreamCallView:
StreamExternalWalletSignerServer sits on the view’s end of the stream. It is constructed
with the view’s stream and passed to WithExternalWalletSigner; each Sign call sends a
SigRequest message and blocks for the matching SignResponse, and Done sends a Done
message once no further signatures are needed.StreamExternalWalletSignerClient sits on the application’s end. It is constructed with a
SignerProvider over the local wallet, and its Respond() loop signs each incoming
SigRequest and writes back a SignResponse until the Done message arrives.All three message kinds travel inside a StreamExternalWalletMsg envelope (Type plus
JSON-encoded Raw).
Respond() returns nil once the peer signals Done. Otherwise it returns the failure that
terminated the exchange:
Respond() over a buffered error channel, so the caller
gets the real transport or decoding error. The buffering matters: it lets that goroutine
complete its send and exit rather than blocking forever on a channel nobody reads;Timeout waiting for stream input exceeded if no message arrives within the configured
timeout. NewStreamExternalWalletSignerClient defaults to one hour; use
NewStreamExternalWalletSignerClientWithTimeout to choose another value. A timeout now means
what it says — the stream went quiet — rather than masking a stream error.However Respond() ends, it signals its receive goroutine to stop before returning, so a
request decoded after the caller has given up is dropped rather than left waiting for a
consumer that will never come. The client is therefore single-use: once Respond() has
returned, no further signatures are served on that stream.
Once fully endorsed, the transaction metadata is distributed to all recipients so they can track and spend their new tokens. The initiator then uses the OrderingView to broadcast the transaction envelope to the network’s ordering service.
The FinalityView allows applications to wait for a transaction to be committed to the ledger. Internally, Panurus’s Network Service listens for ledger events. When a transaction reaches finality, the Network Service notifies Panurus, which then updates the local Transactions DB and Tokens DB to reflect the new state.
Waiting is push-first: each waiter registers a status listener on the local database (TTXDB or AuditDB), checks the status once to cover the registration race, and then blocks until a status event arrives. Status writes — including the confirmed commit path — push the event in-process. As a safety net for lost push events, a single fallback poller per database batch-fetches the statuses of all waited-on transactions (GetStatuses) and re-publishes terminal ones; it sweeps at the smallest polling interval among the active waiters (WithPollingTimeout, default 1s) and stops when no waiter remains. There is no per-transaction polling.
Panurus includes an automatic recovery mechanism to handle pending transactions that may have lost their finality listeners due to node restarts, network interruptions, or other failures.
The recovery service is part of the Storage Service and is instantiated by the Network Service to recover transactions from either TTXDB (for regular transactions) or AuditDB (for auditor nodes).
For detailed information about the recovery mechanism, see:
Optional: token.tms.<name>.services.network.fabric.recovery