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
participant Initiator
participant Recipient
participant Auditor
participant Network as Network Service
participant Ledger as DLT / Ledger
box darkgreen Panurus Stack
participant Initiator
participant Recipient
participant Auditor
participant Network
end
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
Note over Initiator: 5. Finality Tracking
Initiator->>+Network: Listen for Finality
Ledger-->>-Network: Transaction Committed
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.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 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 (an extension attack). 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. When RecipientRequest.MultiSig is true, the initiator may send an additional MultisigRecipientData after the first exchange; the responder registers identities and updates bindings as in code. Each individual component identity is already attested through nonce/signature binding during the single-recipient phase.
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->>R: ExchangeRecipientRequest{TMSID, WalletID, RecipientData(local), Nonce}
end
rect rgba(255, 245, 238, 0.5)
Note over R: Phase 2 - Responder processing with attestation
R->>R: Reject if Nonce is empty
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 |
withdrawal_req |
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. Single-shot: the initiator sends a WithdrawalRequest; the issuer registers the recipient identity and returns the session for the subsequent issuance flow.
sequenceDiagram
autonumber
participant I as Initiator (RequestWithdrawalView)
participant Iss as Issuer (ReceiveWithdrawalRequestView)
I->>I: Resolve recipient identity (caller-supplied or wallet-generated)
I->>Iss: Envelope{t:"withdrawal_req", b:WithdrawalRequest{TMSID, RecipientData, TokenType, Amount, NotAnonymous}}
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
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); 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; 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.
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; assemble
R-->>I: Envelope{t:"tx_resp", b:TransactionPayload{Raw}}
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.The CollectEndorsementsView is responsible for gathering all signatures required to make a transaction valid:
AuditApproveView.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.
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