panurus

Token Transaction (TTX) Service

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.

Transaction Lifecycle

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

Transaction Creation

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:

Responder Threat Model

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:

  1. Validate message type, encoding, structure, and protocol state before using nested fields.
  2. Authenticate the party or identity authorized for an operation before releasing signatures or recipient data.
  3. Validate the complete structure before mutating local state, and bind acknowledgements to the transaction that was reviewed.
  4. Return errors for malformed or inconsistent input instead of panicking.

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.

Identity Management

To issue or transfer tokens, the initiator must acquire the recipient’s identity. The TTX service provides interactive protocols for this purpose.

Requesting Recipient Identities

The RequestRecipientIdentityView allows an initiator to request a fresh recipient identity from a counterparty. This process ensures that:

Recipient Protocol Flows (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:

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 / RespondRequestRecipientIdentityView

sequenceDiagram
    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 / RespondExchangeRecipientIdentitiesView

sequenceDiagram
    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

PolicyIdentity — Boolean-Expression-Governed Ownership

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.

Creating a PolicyIdentity

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.

Policy Expression Syntax

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.

Spending — OR Policy

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),
))

Spending — AND Policy

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.

Spend Coordination Wire Flow

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.

Wallet and Authorization

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.

Interactive Protocol Versioning

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.

Message Types

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.

Strict Mode and Errors

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:

All satisfy errors.Is. VersionCompatibility / IsCompatible(local, remote) declare which versions interoperate (v1 ↔ v1 only).

Metrics

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.

Withdrawal Flow

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.

Caller-supplied recipient data (external wallet)

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.

Token Upgrade 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), 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.

Endorsement and Signature Collection

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.

Auditor Approval Flow

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

Collect Actions Flow

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.

Token Operations

The TTX service supports three primary operations through the TokenRequest API:

Issue

Allows authorized issuers to create new tokens. The service:

  1. Retrieves the issuer’s identity from the internal Identity Service.
  2. Generates an “Issue Action” using the driver-specific logic.
  3. Adds the action and its metadata to the transaction request.

Transfer

Enables the transfer of token ownership. The service:

  1. Uses the Selector Service to pick spendable tokens (UTXOs) that cover the requested amount.
  2. Locks the selected tokens in the local TokenLocks table to prevent double-spending.
  3. Generates a “Transfer Action” that consumes the selected tokens and creates new ones for the recipients.

Redeem

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:

  1. Add the redeem action with tx.Redeem(...).
  2. If the issuer endpoint cannot be resolved automatically, pass ttx.WithFSCIssuerIdentity(...) so the initiator can contact the issuer for endorsement.
  3. Optionally pass ttx.WithIssuerPublicParamsPublicKey(...) to pin which issuer public-parameters signing key must authorize the redeem.
  4. Run 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.

Collecting Endorsements

The CollectEndorsementsView is responsible for gathering all signatures required to make a transaction valid:

Streaming signer for external wallets (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:

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:

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.

Distribution and Ordering

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.

Finality and Discovery

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.

Transaction Recovery

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: