Part of the Fee Module epic (#366). This is the first deliverable — the fee engine sizes fees in CC, fails fast for users with no CC, and the reconciler tracks the operator's CC balance, so CC must be readable before any fee work lands. It's also independently useful: users can't currently see their Canton Coin.
Current state
Canton Coin is native Amulet (Splice.Amulet.Amulet) and is not indexed today. Balances in the middleware are served from the indexer's indexer_balances table (both shipped api-server configs run token_provider.mode: indexer), maintained per template by bespoke decoders:
cip56_package_id + filter_mode: all → every CIP56.Events.TokenTransferEvent (Amulet emits none of these).
utility_registry_holding_package_id → Utility.Registry.Holding.V0.Holding (USDCx-style balances), via NewHoldingDecoder.
Amulet is a different template with a different field shape, so no existing subscription captures it. Scope: read-only balance display. No CC transfer support.
Amulet contract shape
Unlike Utility.Registry.Holding ({registrar, instrument, owner, amount}), an Amulet is roughly:
Splice.Amulet.Amulet
dso : Party -- the DSO party = instrument admin for CC
owner : Party
amount : ExpiringAmount { initialAmount : Decimal, createdAt : Round, ratePerRound : Decimal }
Plus separate LockedAmulet contracts for locked CC. Amulet is 10 decimals.
Indexer changes
1. Config field (pkg/indexer/config.go)
// AmuletPackageID is the DAML package ID (or #package-name) for Splice Amulet
// (Splice.Amulet.Amulet), i.e. Canton Coin. Required to track CC balances —
// without it the indexer never sees Amulet create/archive events and CC stays
// at 0. Leave empty to disable Amulet tracking.
AmuletPackageID string `yaml:"amulet_package_id"`
2. Template subscription (pkg/app/indexer/server.go, indexerTemplateIDs)
if cfg.AmuletPackageID != "" {
ids = append(ids, streaming.TemplateID{
PackageID: cfg.AmuletPackageID,
ModuleName: "Splice.Amulet",
EntityName: "Amulet",
})
}
3. Amulet decoder (pkg/indexer/engine/decoder.go) — mirrors NewHoldingDecoder
const (
amuletModule = "Splice.Amulet"
amuletEntity = "Amulet"
)
// NewAmuletDecoder decodes Splice Amulet (Canton Coin) holding lifecycle events
// into balance changes, keyed by the DSO party as instrument admin and a fixed
// "CC" instrument id. LockedAmulet contracts are ignored (locked CC is not a
// spendable balance), matching how NewHoldingDecoder excludes locked holdings.
func NewAmuletDecoder(packageID string) Decoder {
return &holdingLikeDecoder{
module: amuletModule,
entity: amuletEntity,
toChange: func(e *lapiv2.CreatedEvent) (*indexer.HoldingChange, error) {
fields := values.RecordFields(e.CreateArguments)
owner := values.Party(fields["owner"])
dso := values.Party(fields["dso"])
amount := amuletAmount(fields["amount"]) // see "Amount decision" below
return &indexer.HoldingChange{
ContractID: e.ContractId,
Owner: owner,
InstrumentAdmin: dso,
InstrumentID: "CC",
Amount: amount,
LedgerOffset: e.Offset,
}, nil
},
}
}
Register it in NewMultiDecoder when AmuletPackageID != "". No new migration — it writes into the existing indexer_balances / indexer_holdings tables via Store.ApplyBalanceDelta (CREATE increments owner, ARCHIVE decrements).
Amount decision (needs verification against the Amulet DAR)
ExpiringAmount is not a flat number — CC decays by a holding fee each round. Two options:
- Face value — sum
amount.initialAmount. Simplest; slightly overstates as fees accrue between rounds.
- Interface view — read the normalized
amount from the Splice.Api.Token.HoldingV1 interface view (the SDK already queries this interface in pkg/cantonsdk/token/client.go), which reflects the round-adjusted amount.
Recommend confirming what the HoldingV1 view exposes and preferring it for accuracy; fall back to initialAmount if the view isn't available on the stream. Document the choice.
api-server changes — pure config, no code
CC surfaces via ERC-20 balanceOf (not eth_getBalance, which is hard-wired to 0 as the synthetic gas token). Two additions:
# token_provider.indexer.instruments — map symbol -> instrument admin (DSO party)
CC: "${CANTON_CC_ISSUER_PARTY}"
# token.supported_tokens — read-only token entry (no external_transfer)
"0xCC00000000000000000000000000000000000001":
name: "Canton Coin"
symbol: "CC"
decimals: 10
instrument_id: "CC" # must match InstrumentID the Amulet decoder stores
Apply across pkg/config/defaults/config.{api-server,indexer}.*.yaml and the deployment charts. Omitting external_transfer (and adding no transfer wiring) keeps CC read-only.
Acceptance criteria
Part of the Fee Module epic (#366). This is the first deliverable — the fee engine sizes fees in CC, fails fast for users with no CC, and the reconciler tracks the operator's CC balance, so CC must be readable before any fee work lands. It's also independently useful: users can't currently see their Canton Coin.
Current state
Canton Coin is native Amulet (
Splice.Amulet.Amulet) and is not indexed today. Balances in the middleware are served from the indexer'sindexer_balancestable (both shipped api-server configs runtoken_provider.mode: indexer), maintained per template by bespoke decoders:cip56_package_id+filter_mode: all→ everyCIP56.Events.TokenTransferEvent(Amulet emits none of these).utility_registry_holding_package_id→Utility.Registry.Holding.V0.Holding(USDCx-style balances), viaNewHoldingDecoder.Amulet is a different template with a different field shape, so no existing subscription captures it. Scope: read-only balance display. No CC transfer support.
Amulet contract shape
Unlike
Utility.Registry.Holding({registrar, instrument, owner, amount}), an Amulet is roughly:Plus separate
LockedAmuletcontracts for locked CC. Amulet is 10 decimals.Indexer changes
1. Config field (
pkg/indexer/config.go)2. Template subscription (
pkg/app/indexer/server.go,indexerTemplateIDs)3. Amulet decoder (
pkg/indexer/engine/decoder.go) — mirrorsNewHoldingDecoderRegister it in
NewMultiDecoderwhenAmuletPackageID != "". No new migration — it writes into the existingindexer_balances/indexer_holdingstables viaStore.ApplyBalanceDelta(CREATE increments owner, ARCHIVE decrements).Amount decision (needs verification against the Amulet DAR)
ExpiringAmountis not a flat number — CC decays by a holding fee each round. Two options:amount.initialAmount. Simplest; slightly overstates as fees accrue between rounds.amountfrom theSplice.Api.Token.HoldingV1interface view (the SDK already queries this interface inpkg/cantonsdk/token/client.go), which reflects the round-adjusted amount.Recommend confirming what the HoldingV1 view exposes and preferring it for accuracy; fall back to
initialAmountif the view isn't available on the stream. Document the choice.api-server changes — pure config, no code
CC surfaces via ERC-20
balanceOf(noteth_getBalance, which is hard-wired to 0 as the synthetic gas token). Two additions:Apply across
pkg/config/defaults/config.{api-server,indexer}.*.yamland the deployment charts. Omittingexternal_transfer(and adding no transfer wiring) keeps CC read-only.Acceptance criteria
amulet_package_idconfig field added to the indexer config and default configs.indexerTemplateIDswhen configured.NewAmuletDecoderdecodes Amulet CREATE/ARCHIVE intoHoldingChange, excludesLockedAmulet, registered in the multi-decoder.balanceOfon the CC contract returns the indexed CC balance for a registered user.external_transfer, no transfer choices).