This document provides a comprehensive guide to upgrading components within a Panurus application. Upgradability is essential for long-term maintenance, allowing for security patches, feature additions, and protocol migrations.
Panurus manages upgradability at three distinct layers:
Panurus manages token upgrades using two distinct mechanisms: the Atomic “Burn and Re-issue” protocol for across-format migrations and In-place Upgrades for backward-compatible transitions.
In-place upgrades allow Panurus to spend tokens from a previous driver version or format (e.g., Fabtoken) directly as if they were native to the current driver (e.g., ZKAT-DLOG), without requiring an explicit ledger transaction first.
The current driver determines compatibility based on several criteria:
SupportedTokenFormats().When in-place upgrade is not possible (e.g., moving to a completely incompatible cryptographic curve or increasing precision beyond limits), Panurus implements an atomic “Burn and Re-issue” protocol.
Two disjoint families of formats reach this path:
maxPrecision — cleartext outputs whose value range the current driver cannot represent.[!NOTE] Eligibility is the complement of in-place support, by design. For Fabtoken-to-DLog upgrades, a token’s precision is either
<= maxPrecision(in-place support, criterion 2 above — no issuer needed) or> maxPrecision(this path — issuer sign-off required because the value cannot be safely reinterpreted in a lower-precision format). These two ranges must never overlap: a driver that also accepted<= maxPrecisiontokens into the Burn-and-Re-issue eligibility list would leave no token that ever needs this path, since every upgrade-eligible token would already be directly spendable. Whichever family a format belongs to, a format the driver already reports inSupportedTokenFormats()is always rejected here: burning a token that is perfectly spendable and minting a replacement would be pure loss. See theServicedoc comment intoken/core/zkatdlog/nogh/v1/crypto/upgrade/service.gofor the implementation-level invariant.
A ZKAT-DLOG output is a Pedersen commitment, and its token.Format is a digest that covers the
Pedersen generators of the public parameters that produced it (SupportedTokenFormat in
token/core/zkatdlog/nogh/v1/token/service.go). Regenerating the public parameters with different
generators — or on a different curve — therefore renames every token created before: the driver
no longer lists those formats in SupportedTokenFormats(), transfers skip the tokens, and they show
up as unspendable even though they are perfectly safe and unspent on the ledger.
Such a token cannot be reinterpreted in place: its commitment only opens under the bases of the generation that created it. It goes through the issuer-mediated path instead, and the issuer needs those old bases to learn what to re-issue.
UnsupportedTokensIteratorBy). For a DLog token, the
ledger entry it gets back carries both the commitment (LedgerToken.Token) and its
opening — type, value, blinding factor — in LedgerToken.TokenMetadata.upgrade.PublicParamsHistory.ByFormat), and puts that hash in the upgrade proof
(Proof.PublicParamsHashes, one entry per token, empty for Fabtoken entries).PublicParamsByHash), re-checks that those
public parameters actually generate the format the token was recorded with, and only then
opens the commitment with their Pedersen bases (upgrade.PublicParamsHistory.ByHashAndFormat
followed by Token.ToClear). This recovers the type and the value to re-issue. If the issuer
does not store that generation at all, it falls back to resolving the generation by format
(ByFormat); a generation it does store but which does not generate the token’s format is
still refused. The opening is validated against the curve of the retrieved public parameters
before any commitment arithmetic runs.ttx.Transaction.Upgrade). The ledger side
is format-agnostic: the issue action’s inputs must exist and are deleted atomically with the new
issuance, so no old-format validation logic is needed on the ledger.The declared hash is only a lookup hint that saves the issuer a scan — it is never trusted on its own. Because the format digest is recomputed from the retrieved public parameters and compared with the format recorded on the ledger, the only bases the issuer will ever use are the ones that demonstrably produced that token; and because the opening must match the commitment, the owner cannot inflate the type or the value it gets back.
An upgrade request is processed before its signatures are verified, so its size is bounded: at most
upgrade.MaxUpgradeRequestTokens (256, matching driver.DefaultResourceLimits().MaxInputs) tokens
per request, and each distinct format is resolved once per request rather than once per token.
Panurus records the public parameters in two places, and both matter for this flow:
PublicParams table (TokenDB) — every generation of public parameters the node ever
observed. StorePublicParams never overwrites an existing row, so the table is a history, and
PublicParamsByHash / PublicParamsHashes expose it (surfaced to drivers through
driver.QueryEngine). This is what makes the upgrade possible at all.Requests.pp_hash (TTXDB / AuditDB) — the hash of the public parameters in force when each
transaction was recorded, i.e. a per-txID trace of which generation created a token.[!IMPORTANT] The issuer must retain the old public parameters. An issuer whose
PublicParamstable no longer holds the generation that created a token cannot open its commitment and will refuse the upgrade (failed to resolve the public parameters of token …). Never prune that table, and when rebuilding an issuer’s TokenDB from scratch, re-import every historical public parameters version before running upgrades. A node that joined the network after a regeneration only has the generations published since it joined.
[!WARNING] Regenerate public parameters deliberately. Since Pedersen generators are derived deterministically from the driver name, driver version and curve (
PublicParams.GeneratePedersenParameters), re-runningtokengenfor the same driver, version and curve reproduces the same bases and therefore the same formats — no upgrade needed. Formats only change when the curve, the driver version, or the generation procedure itself changes. Before publishing new public parameters, compareSupportedTokenFormats()before and after: if the set changes, every existing token needs this upgrade path, and owners must be online to run it.
Developers can use the tokens service to find tokens that require an upgrade.
// Get the tokens service for a specific TMS
tms, _ := token.GetManagementService(context, token.WithTMSID(myTMSID))
tokensService, _ := tokens.GetService(context, tms.ID())
// Iterate over tokens of type "USD" that the current driver cannot spend
it, err := tokensService.UnsupportedTokensIteratorBy(
context.Context(),
myWalletID,
"USD",
)
if err != nil {
return err
}
defer it.Close()
var toUpgrade []token.LedgerToken
for {
tok, _ := it.Next()
if tok == nil { break }
toUpgrade = append(toUpgrade, *tok)
}
The issuer uses the ttx package to wrap the upgrade logic.
// Inside a Responder View
tx, err := ttx.NewTransaction(context, nil, ttx.WithTMSID(upgradeRequest.TMSID))
// The Upgrade call consumes old tokens and issues new ones in one atomic step
err = tx.Upgrade(
context.Context(),
issuerWallet,
upgradeRequest.RecipientIdentity,
upgradeRequest.Challenge,
upgradeRequest.Tokens, // Old tokens from ledger
upgradeRequest.Proof, // ZK-Proof or Signature
)
Symptom: a wallet’s balance still counts tokens, but transfers silently leave them behind, and they are reported as unspendable. The tokens are safe and unspent on the ledger — the driver simply no longer recognises their format. Recover them as follows.
Step 1 — Confirm the diagnosis. Compare the formats the driver supports now with the format
recorded on a stranded token. If the token’s format is absent from SupportedTokenFormats(), and
the public parameters were regenerated (or the curve or driver version changed), this is the case
this runbook covers. UnsupportedTokensIteratorBy (see
Identifying Unsupported Tokens) lists exactly the
affected tokens for a wallet and token type.
Step 2 — Pre-flight: check that both sides still hold the old generation. The upgrade cannot
succeed if the PublicParams table no longer contains the generation that created the tokens, and
this applies to the owner and to the issuer, for different reasons and with different error
messages:
failed to resolve the public parameters of token ….GenUpgradeProof fails with unsupported token format …: no stored public parameters generate
token format …. There is no fallback — nothing resolves the generation from the ledger or from
the counterparty on the owner’s behalf.Verify on both nodes before starting: list the stored hashes with QueryEngine.PublicParamsHashes
and confirm that one of them produces the stranded format. If a generation is missing, re-import it
first (Step 5). An owner’s node usually has it, because it observed those public parameters while
the tokens were being created — but a rebuilt owner database, or a wallet restored onto a node that
joined after the regeneration, is in exactly the same position as a pruned issuer.
Step 3 — Owner side: request the upgrade. This is the step that starts the protocol. The owner sends the stranded tokens to the issuer and then acts as the recipient of the replacement transaction:
// Inside the owner's initiator view
tms, err := token.GetManagementService(context, token.WithTMSID(myTMSID))
wallet, err := tms.WalletManager().OwnerWallet(context.Context(), myWalletID)
tokensService, err := tokens.GetService(context, tms.ID())
// the stranded tokens, from Step 1
it, err := tokensService.UnsupportedTokensIteratorBy(context.Context(), wallet.ID(), "USD")
stranded, err := collections.ReadAll(it)
// ask the issuer to burn them and re-issue the equivalent value
recipient, session, err := ttx.RequestTokensUpgrade(
context,
issuerIdentity,
myWalletID,
stranded,
false, // notAnonymous
token.WithTMSID(tms.ID()),
)
if err != nil {
return nil, err
}
// the owner now becomes the responder: receive the transaction the issuer assembled,
// check that it pays the recipient identity above, accept it, and wait for finality
return context.RunView(nil, view.AsResponder(session), view.WithViewCall(
func(context view.Context) (any, error) {
tx, err := ttx.ReceiveTransaction(context)
if err != nil {
return nil, err
}
outputs, err := tx.Outputs(context.Context())
if err != nil {
return nil, err
}
if outputs.ByRecipient(recipient).Count() == 0 {
return nil, errors.Errorf("no output assigned to [%s]", recipient)
}
if _, err := context.RunView(ttx.NewAcceptView(tx)); err != nil {
return nil, err
}
_, err = context.RunView(ttx.NewFinalityView(tx, ttx.WithTimeout(1*time.Minute)))
return tx.ID(), nil
},
))
Use ttx.RequestTokensUpgradeForRecipient instead when the owner already holds the recipient data
and has registered it with wallet.RegisterRecipient.
Step 4 — Issuer side: respond. The issuer receives the request with
ttx.ReceiveTokensUpgradeRequest(context), then assembles the atomic burn-and-re-issue transaction
as shown in
The Upgrade Transaction (Issuer Side). The
issuer verifies the proof, opens each commitment with the bases of the generation that produced it,
and re-issues the recovered type and value.
Step 5 — If a TokenDB was rebuilt, re-import the history first. This applies to whichever node
is missing the generation, owner or issuer; the procedure is the same on both. A node only learns
the current generation of public parameters from the ledger, so older generations must be restored
from an operator-held copy — the tokengen output, or a backup of the previous public parameters
file. Store each historical generation before running any upgrade:
tokensService, err := tokens.GetService(context, tms.ID())
for _, raw := range historicalPublicParams { // oldest first
if err := tokensService.StorePublicParams(context.Context(), raw); err != nil {
return err
}
}
StorePublicParams never overwrites an existing row, so re-importing a generation the node already
has is harmless and the call is safe to repeat. After re-importing, re-run the Step 2 check: the
stranded format must now be produced by one of the stored hashes.
Step 6 — Verify. The upgraded tokens carry a format the driver supports, so they leave
UnsupportedTokensIteratorBy and become transferable; the balance for the wallet is unchanged,
because the re-issued outputs carry the same type and value. Re-run Step 1 and expect an empty
iterator.
[!NOTE] After a node restart or a database rebuild, re-run the Step 2 check before anything else. The whole flow depends on the
PublicParamstable still holding every generation the node observed; a table restored from a partial backup, or a node that joined the network after the regeneration, silently lacks the bases needed to open older commitments.
The commitment upgrade path is covered at two levels.
token/core/zkatdlog/nogh/v1/crypto/upgrade/service_comm_test.go covers the driver
side: the round trip, an issuer that dropped the historical parameters, proof tampering (wrong
hash, no hash, bad opening), an already-supported format, batches mixing fabtoken and dlog
inputs, and tokens from several generations in one request. The format derivation the path
depends on is pinned in token/core/zkatdlog/nogh/v1/token/service_formats_test.go.fungible.TestDLogTokensUpgrade, wired as label T4 of the update suite
(make integration-tests-update-t4), runs the whole flow on a real Fabric network: tokens are
issued under one generation of public parameters, the parameters are regenerated with different
Pedersen bases, the tokens become unspendable exactly as described in Step 1 of the runbook
above, the issuer-mediated upgrade recovers them, and they are transferred again afterwards. It
also covers a wallet that holds a stranded token next to a current one, so only the stranded one
is upgraded.upgrade.MaxUpgradeRequestTokens (256) tokens, but that ceiling is a denial-of-service bound, not a batch size to aim for: the ledger limits bite first.PublicParameters of the new driver before initiating a mass upgrade to ensure the target format is correct.Panurus handles driver transitions gracefully during its startup sequence.
When the Token Management Service (TMS) initializes, it performs a PostInit sequence. It compares the formats of all tokens in the local database against the SupportedTokenFormats() reported by the currently loaded driver.
spendable = false in the local DB.spendable = true.Drivers like fabtoken or zkatdlog derive their format string from their PublicParameters (e.g., precision, identity types).
// Example of how a driver might calculate its format
func SupportedTokenFormat(precision uint64) (token.Format, error) {
hasher := sha256.New()
hasher.Write([]byte("zkatdlog"))
hasher.Write([]byte(fmt.Sprintf("%d", precision)))
return token.Format(hex.EncodeToString(hasher.Sum(nil))), nil
}
For more information on how token formats are used in Panurus’s token service, see the Tokens Service documentation.
Panurus provides a structured approach for upgrading public parameters across the network. This process ensures that all participants can smoothly transition to new cryptographic parameters while maintaining compatibility.
sequenceDiagram
autonumber
actor Admin as Administrator<br/>(or Issuer)
participant Fabric as Ledger<br/>(Fabric/FabricX/etc)
box darkgreen Panurus Stack
participant Network as Network Service
participant Provider as TMS Provider
participant TMS as Token Management<br/>Service
participant Driver as Driver API
end
Admin->>+Fabric: Generate & Publish<br/>New Public Parameters
Fabric->>+Network: Ledger Update<br/>Notification
deactivate Fabric
Network->>+Provider: GetManagementServiceProvider
Provider->>+TMS: Update/Create TMS<br/>(with new PP)
TMS->>+Driver: Set Public Parameters
Driver-->>-TMS: Ready for Token Operations
TMS-->>-Provider: TMS Instance
Provider-->>-Network: TMS Response
Network-->>-Admin: Update Complete<br/>(Optional Notification)
PublicParamsFetcher interfaceTokenManagerServiceProvider compares new vs existing parameters and updates the TMS if changedAfter the upgrade process completes, administrators can verify that all nodes are synchronized by retrieving the public parameters using the Token API’s PublicParametersManager. This ensures that the new parameters have been successfully propagated throughout the network.
// Get the TMS for verification
tms, err := token.GetManagementService(context, token.WithTMSID(myTMSID))
if err != nil {
return err
}
// Get the Public Parameters Manager
ppm := tms.PublicParametersManager()
// Retrieve the current public parameters
currentPP := ppm.PublicParameters()
if currentPP == nil {
return errors.New("public parameters not available")
}
// Verify the parameters match the expected values
// (Implementation-specific validation would go here)
Balance API to monitor the ratio of spendable vs. unspendable tokens. A sudden drop in spendable balance indicates a driver mismatch.The local storage (SQL) uses a “Lazy Creation” strategy.
Panurus uses CREATE TABLE IF NOT EXISTS. This handles fresh installs perfectly but does not manage ALTER TABLE operations for existing databases.
-- Panurus executes this on startup
CREATE TABLE IF NOT EXISTS fsc_tokens (
tx_id TEXT NOT NULL,
idx INT NOT NULL,
-- ... other columns
ledger_type TEXT DEFAULT '', -- New columns added in SDK updates
PRIMARY KEY (tx_id, idx)
);
If a new version of Panurus adds a column (e.g., ledger_metadata or spent_at), the IF NOT EXISTS clause will prevent the new schema from being applied to an existing table.
ALTER TABLE fsc_tokens ADD COLUMN IF NOT EXISTS ledger_metadata BYTEA;
vault.db). Panurus’s Vault service can re-sync its state by scanning the ledger, though this may take time depending on the ledger size.ALTER statements.[!WARNING] Breaking change: the
Tokenstable (fsc_tokens) gained a newredeemed BOOL NOT NULL DEFAULT falsecolumn, and allINSERT/SELECTstatements against this table were updated accordingly (seeIssuedBalance/RedeemedBalancein Issuer Wallet). Existing deployments must apply this migration before upgrading, otherwise the SDK’sINSERT INTO fsc_tokens (..., redeemed)statements will fail against the old schema:ALTER TABLE fsc_tokens ADD COLUMN IF NOT EXISTS redeemed BOOL NOT NULL DEFAULT false;Any node that also stores tokens locally (e.g. an issuer that is also an owner or auditor) needs this migration applied to its
TokenDBbefore upgrading.
Wallets / IdentityConfigurations linkagePanurus links every Wallets row to the IdentityConfigurations row it originated
from via a conf_id column: IdentityConfigurations.conf_id is a deterministic hash
of (id, type, url) (see IdentityConfiguration.UniqueID()), declared UNIQUE NOT
NULL, and Wallets.conf_id is a hard FOREIGN KEY reference to it, also NOT NULL.
Because Panurus only ever runs CREATE TABLE IF NOT EXISTS, existing databases
created before this change will not get these columns automatically. Before starting
an updated SDK against an existing database, migrate manually:
-- 1. Add the column to IdentityConfigurations first (FK target).
ALTER TABLE fsc_identity_configurations ADD COLUMN IF NOT EXISTS conf_id TEXT;
-- Backfill: conf_id = base64(sha256(id || '@' || type || '@' || url)), matching
-- IdentityConfiguration.UniqueID(). Run this from application code (or a one-off
-- script using the same hash function) rather than in pure SQL.
ALTER TABLE fsc_identity_configurations ALTER COLUMN conf_id SET NOT NULL;
ALTER TABLE fsc_identity_configurations ADD CONSTRAINT uq_identity_configurations_conf_id UNIQUE (conf_id);
-- 2. Add the column to Wallets and backfill it from the matching configuration
-- (join on however the wallet's owning configuration is known, e.g. wallet_id/id).
ALTER TABLE fsc_wallets ADD COLUMN IF NOT EXISTS conf_id TEXT;
-- Backfill fsc_wallets.conf_id from fsc_identity_configurations.conf_id here.
ALTER TABLE fsc_wallets ALTER COLUMN conf_id SET NOT NULL;
ALTER TABLE fsc_wallets ADD CONSTRAINT fk_wallets_conf_id
FOREIGN KEY (conf_id) REFERENCES fsc_identity_configurations (conf_id);
On SQLite, use the ALTER TABLE ... ADD COLUMN form (SQLite cannot add a NOT NULL
column without a default or a FK constraint in one statement) — add the columns
nullable, backfill, then rebuild the table (CREATE TABLE ... AS SELECT + rename) to
add the NOT NULL and FOREIGN KEY constraints, since SQLite does not support adding
constraints via ALTER TABLE after the fact.
Requests recovery-claim columnsThe Transaction Recovery Service added two nullable
columns to the Requests table (fsc_requests), used to lease pending transactions
to a claiming instance: recovery_claimed_by TEXT and recovery_claim_expires_at
TIMESTAMP, plus a partial index on (status, recovery_claim_expires_at, stored_at)
WHERE status = 1. Since both columns are nullable, this is a non-breaking change —
existing rows are simply treated as unclaimed — but CREATE TABLE IF NOT EXISTS still
will not add them to a pre-existing table, so recovery will fail to claim transactions
until the columns exist. Migrate manually before upgrading:
ALTER TABLE fsc_requests ADD COLUMN IF NOT EXISTS recovery_claimed_by TEXT;
ALTER TABLE fsc_requests ADD COLUMN IF NOT EXISTS recovery_claim_expires_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_recovery_claim_fsc_requests
ON fsc_requests (status, recovery_claim_expires_at, stored_at) WHERE status = 1;
This table is shared by TTXDB, AuditDB, and TokenDB (each defines the same
CREATE TABLE IF NOT EXISTS for Requests), so the migration only needs to run once
against the physical table they share.
TokenLocks created_at column typeThe TokenLocks table (<prefix>_token_locks) was created with created_at
TIMESTAMP (without time zone) in earlier releases. The GetSchema definition in
TokenLockStore has since
been updated to TIMESTAMPTZ. On PostgreSQL the two types are distinct — a
TIMESTAMP column silently drops timezone information — so existing deployments keep
a TIMESTAMP column and the time-zone-aware comparison logic introduced by the
token-lock lease-expiry feature will produce skewed results until the column is
migrated. CREATE TABLE IF NOT EXISTS does not update the column type of a
pre-existing table.
Migrate manually before upgrading:
ALTER TABLE <token_locks_table>
ALTER COLUMN created_at TYPE TIMESTAMPTZ
USING created_at AT TIME ZONE 'UTC';
Replace <token_locks_table> with the actual table name used by your deployment
(e.g. fsc_token_locks). This migration is a no-op on SQLite, which stores all
datetime values as text and is unaffected by the type name change.
Panurus relies heavily on Protocol Buffers (Protobuf) for serializing all core objects, including Public Parameters, Token Requests, and individual Actions. This choice is fundamental to Panurus’s ability to evolve over time while maintaining compatibility between nodes running different software versions.
Protobuf provides a binary serialization format that is both efficient and highly extensible. Panurus leverages several Protobuf features to ensure long-term stability:
Priority field to a TokenRequest). Older nodes receiving these messages will simply ignore the unknown fields and continue processing the data they recognize.0 for integers, "" for strings), allowing the new logic to handle them gracefully.PublicParameters message at the driver API level looks like this:
message PublicParameters {
string identifier = 1; // e.g., "zkatdlognogh/v1"
bytes raw = 2; // Opaque driver-specific bytes
}
This allows the core SDK to handle the delivery and storage of public parameters without needing to understand their internal structure. The raw bytes are only unmarshalled by the specific driver version identified by the identifier.
For more details on the specific Protobuf messages used by each driver, see:
IssueMetadata or PublicParameters) include a map<string, bytes> extra_data or application field. This allows developers to attach arbitrary information to transactions or configurations without modifying the underlying .proto definitions, avoiding the need for a full protocol migration for application-specific changes..proto file, it must never be reassigned to a different field, even if the original field is deprecated.proto3 defaults or explicitly check for presence to ensure that missing fields from older clients don’t cause crashes.package zkatdlognogh.v2;). This allows both the old and new unmarshallers to coexist in the same codebase.buf breaking)These recommendations are enforced automatically. buf.yaml declares a breaking:
ruleset (FILE), and the proto-breaking job in
.github/workflows/tests.yml runs buf breaking
against the pull request’s base branch on every PR. Wire-incompatible changes — changing
a field’s type, reusing or renumbering a field, deleting a message, and so on — fail CI
before they can merge.
To run the same check locally against your main branch (or any other base ref):
make protos-breaking # diff against local 'main'
make protos-breaking BUF_BREAKING_AGAINST=".git#ref=origin/main"
This is intentionally separate from make checks: a breaking-change check is comparative
(it needs a base ref to diff against), so it only runs on pull requests and not on a plain
push.
Panurus is a v0.x module (github.com/LFDT-Panurus/panurus), so under
SemVer its exported Go API is not yet frozen and may change
between minor releases. Even so, source-level breaking changes to exported symbols are
called out here so downstream code that imports the SDK can adapt during an upgrade.
role.Registry.GetWalletID return type (issue #2063)[!WARNING] Breaking change:
(*Registry).GetWalletIDintoken/services/identity/rolechanged its return type from(string, error)to the newWalletIDResolutionstruct. Code that calls this method directly on a*role.Registrywill not compile until it is updated. Nothing inside the SDK breaks (the only in-tree callers areRegistry.Lookupand the package’s own tests; thewallet.RoleRegistryinterface does not includeGetWalletID), so this affects only downstream code that holds a concrete*Registryand calls.GetWalletIDitself.
Why it changed. The old signature swallowed every storage error and returned
("", nil), which callers read as “no wallet is bound to this identity yet.” A
transient storage failure (timeout, connection reset) therefore masqueraded as an
unregistered identity and fell through the lookup chain to create a duplicate wallet
for an identity that already had one. The (string, error) shape was ambiguous by
design — ("", nil), ("", err) and ("id", nil) each mean something different and the
difference was easy to get wrong. WalletIDResolution makes the three outcomes explicit
so the “not found” vs “could not check” distinction is decided once and cannot collapse
into a silent miss.
Note that any downstream caller was already getting the buggy behaviour on the error
path (("", nil) instead of a real error); the new type is what surfaces that error
honestly.
Migration. Replace the (string, error) destructuring with an explicit branch on the
resolution’s state. Treat anything that is neither Bound() nor Unbound() — i.e.
Failed(), or a zero-value resolution — as “binding unknown, do not fall through to
creation”:
// Before
wID, err := reg.GetWalletID(ctx, identity)
if err != nil {
return err // NOTE: pre-fix this was unreachable — storage errors were swallowed
}
if len(wID) != 0 {
use(wID) // a wallet is bound
}
// After
res := reg.GetWalletID(ctx, identity)
switch {
case res.Bound():
use(res.WalletID) // a wallet is bound
case res.Unbound():
// authoritative miss: safe to fall through and, ultimately, create a wallet
default: // res.Failed(), or a non-authoritative zero value
return res.Err // storage failure — must NOT be treated as "no binding"
}
WalletIDResolution exposes Bound(), Unbound() and Failed() predicates; read
WalletID only when Bound() is true and Err only when Failed() is true. See the
type’s Godoc in token/services/identity/role/registry.go for the full contract.
IsMe / AreMe gained an error return (issue #2066)[!WARNING] Breaking change: the ownership-check methods
IsMeandAreMechanged their return types to add a trailingerror. Every direct caller must be updated before it compiles:
Symbol Before After driver.IdentityProvider.IsMeIsMe(ctx, id) boolIsMe(ctx, id) (bool, error)driver.IdentityProvider.AreMeAreMe(ctx, ids...) []stringAreMe(ctx, ids...) ([]string, error)token.SignatureService.IsMe/.AreMesame same role.LocalMembership.IsMeIsMe(ctx, id) boolIsMe(ctx, id) (bool, error)membership.LocalMembership.IsMeIsMe(ctx, id) boolIsMe(ctx, id) (bool, error)ttx/dep.SignatureService.IsMeIsMe(ctx, id) boolIsMe(ctx, id) (bool, error)
token.SignatureServiceis the public facade, sotms.SigService().IsMe(...)/.AreMe(...)call sites in downstream code are the most likely to break.
Why it changed. The old signatures had no way to report “I could not check”. A storage
failure while resolving ownership was swallowed and flattened into a confident false (for
IsMe) or a cache-only partial slice (for AreMe). A caller then treated an owned identity
as not owned — for example dropping an owned token from the vault during a restart when
the signer cache is cold and the backing store briefly errors. The error return makes the
“couldn’t check” outcome explicit so it can no longer masquerade as an authoritative
“not mine”.
Migration. Destructure the new error and stop on it rather than trusting the boolean or
slice. On error, the boolean must be ignored and the slice is nil (not a partial answer):
// Before
if sigService.IsMe(ctx, id) {
use(id)
}
// After
mine, err := sigService.IsMe(ctx, id)
if err != nil {
return err // could not check — do NOT treat as "not mine"
}
if mine {
use(id)
}
The error distinguishes a caller-driven abort (a cancelled or deadline-exceeded context —
errors.Is(err, context.Canceled) / context.DeadlineExceeded) from a genuine storage
failure, so a cancellation is not misread as a broken backend.
| Component | Responsibility | Mechanism |
|---|---|---|
| Tokens | Owner / Issuer | ttx.Transaction.Upgrade (Burn & Re-issue) |
| Driver | Admin / SDK | PostInit (Automatic Spendability Toggle) |
| Schema | Developer / Admin | Manual SQL ALTER or Database Re-sync |
| SDK API (Go) | Downstream Developer | Update call sites per release notes (e.g. Registry.GetWalletID → WalletIDResolution; IsMe/AreMe → trailing error) |