Skip to content

Canton Coin (CC) read-only balance support #367

Description

@sadiq1971

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_idUtility.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

  • amulet_package_id config field added to the indexer config and default configs.
  • Amulet template subscription appended in indexerTemplateIDs when configured.
  • NewAmuletDecoder decodes Amulet CREATE/ARCHIVE into HoldingChange, excludes LockedAmulet, registered in the multi-decoder.
  • Amount semantics (face vs. interface-view) decided and documented; decoder unit tests cover both create and archive deltas.
  • api-server config exposes CC as a read-only token; balanceOf on the CC contract returns the indexed CC balance for a registered user.
  • No transfer path added for CC (no external_transfer, no transfer choices).
  • Verified end-to-end on a network with real Amulet: a party's CC balance appears via the api-server.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: FeatureAdded to issues and PRs to identify that the change is a new feature.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions