From cf10e4fdc8233624dd4771274d41d86c4fce6fb9 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:23:27 -0600 Subject: [PATCH 1/7] feat: add instrument_id to token config and splice_holding_package_id Add InstrumentID and InstrumentAdmin fields to ERC20Token struct for per-token Canton instrument mapping. InstrumentID is required; InstrumentAdmin defaults to IssuerParty when empty. Add SpliceHoldingPackageID to cantonsdk/token.Config for Splice HoldingV1 interface queries (needed by #218 unified balance query). Update all config YAML files (defaults, test fixtures, e2e-local, devnet) with instrument_id per token and splice_holding_package_id. Closes #212 --- config.e2e-local.yaml | 3 +++ pkg/cantonsdk/token/config.go | 4 ++++ pkg/config/defaults/config.api-server.docker.yaml | 3 +++ .../defaults/config.api-server.local-devnet.yaml | 3 +++ pkg/config/defaults/config.api-server.mainnet.yaml | 2 ++ pkg/config/defaults/config.relayer.docker.yaml | 1 + pkg/config/defaults/config.relayer.local-devnet.yaml | 1 + pkg/config/defaults/config.relayer.mainnet.yaml | 1 + pkg/config/tests/env-substitution.api.yaml | 2 ++ pkg/config/tests/invalid-database-url.api.yaml | 2 ++ pkg/config/tests/minimal.api.yaml | 2 ++ pkg/config/tests/minimal.relayer.yaml | 1 + pkg/config/tests/missing-env.api.yaml | 2 ++ pkg/config/tests/missing-required.relayer.yaml | 1 + .../tests/monitoring-enabled-no-server.relayer.yaml | 1 + .../tests/monitoring-enabled-with-server.relayer.yaml | 1 + pkg/config/tests/unknown-field.relayer.yaml | 1 + pkg/token/config.go | 10 ++++++---- pkg/token/erc20_test.go | 8 ++++---- tests/e2e/devstack/system/system.go | 4 ++-- 20 files changed, 43 insertions(+), 10 deletions(-) diff --git a/config.e2e-local.yaml b/config.e2e-local.yaml index e9e2420f..c63eb0f1 100644 --- a/config.e2e-local.yaml +++ b/config.e2e-local.yaml @@ -55,6 +55,7 @@ canton: token: cip56_package_id: "c8c6fe7c34d96b88d6471769aae85063c8045783b2a226fd24f8c573603d17c2" splice_transfer_package_id: "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281" + splice_holding_package_id: "placeholder-updated-by-bootstrap" bridge: package_id: "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72" @@ -67,10 +68,12 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" "0xDE30000000000000000000000000000000000001": name: "Demo Token" symbol: "DEMO" decimals: 18 + instrument_id: "DEMO" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/cantonsdk/token/config.go b/pkg/cantonsdk/token/config.go index 560d95c9..9397ce3f 100644 --- a/pkg/cantonsdk/token/config.go +++ b/pkg/cantonsdk/token/config.go @@ -10,6 +10,7 @@ type Config struct { CIP56PackageID string `yaml:"cip56_package_id" validate:"required"` SpliceTransferPackageID string `yaml:"splice_transfer_package_id" validate:"required"` + SpliceHoldingPackageID string `yaml:"splice_holding_package_id" validate:"required"` } func (c *Config) validate() error { @@ -31,5 +32,8 @@ func (c *Config) validate() error { if c.SpliceTransferPackageID == "" { return errors.New("splice_transfer_package_id is required") } + if c.SpliceHoldingPackageID == "" { + return errors.New("splice_holding_package_id is required") + } return nil } diff --git a/pkg/config/defaults/config.api-server.docker.yaml b/pkg/config/defaults/config.api-server.docker.yaml index e8a36a1e..c394a94b 100644 --- a/pkg/config/defaults/config.api-server.docker.yaml +++ b/pkg/config/defaults/config.api-server.docker.yaml @@ -46,6 +46,7 @@ canton: token: cip56_package_id: "c8c6fe7c34d96b88d6471769aae85063c8045783b2a226fd24f8c573603d17c2" splice_transfer_package_id: "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281" + splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" bridge: package_id: "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72" @@ -58,10 +59,12 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" "0xDE30000000000000000000000000000000000001": name: "Demo Token" symbol: "DEMO" decimals: 18 + instrument_id: "DEMO" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/config/defaults/config.api-server.local-devnet.yaml b/pkg/config/defaults/config.api-server.local-devnet.yaml index 5646015f..e1f3d9cb 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -41,6 +41,7 @@ canton: token: cip56_package_id: "c8c6fe7c34d96b88d6471769aae85063c8045783b2a226fd24f8c573603d17c2" splice_transfer_package_id: "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281" + splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" bridge: package_id: "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72" @@ -52,10 +53,12 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" "0xDE30000000000000000000000000000000000001": name: "Demo Token" symbol: "DEMO" decimals: 18 + instrument_id: "DEMO" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index 24546067..d8a36430 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -39,6 +39,7 @@ canton: token: cip56_package_id: "${CANTON_CIP56_PACKAGE_ID}" splice_transfer_package_id: "${CANTON_SPLICE_TRANSFER_PACKAGE_ID}" + splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" bridge: package_id: "${CANTON_BRIDGE_PACKAGE_ID}" @@ -50,6 +51,7 @@ token: name: "Demo Token" symbol: "DEMO" decimals: 18 + instrument_id: "DEMO" native_balance_wei: "1000000000000000000000" eth_rpc: diff --git a/pkg/config/defaults/config.relayer.docker.yaml b/pkg/config/defaults/config.relayer.docker.yaml index 35e298b5..672c8da5 100644 --- a/pkg/config/defaults/config.relayer.docker.yaml +++ b/pkg/config/defaults/config.relayer.docker.yaml @@ -53,6 +53,7 @@ canton: token: cip56_package_id: "c8c6fe7c34d96b88d6471769aae85063c8045783b2a226fd24f8c573603d17c2" splice_transfer_package_id: "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281" + splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" bridge: package_id: "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72" diff --git a/pkg/config/defaults/config.relayer.local-devnet.yaml b/pkg/config/defaults/config.relayer.local-devnet.yaml index 8d523ea5..f20c60d1 100644 --- a/pkg/config/defaults/config.relayer.local-devnet.yaml +++ b/pkg/config/defaults/config.relayer.local-devnet.yaml @@ -55,6 +55,7 @@ canton: token: cip56_package_id: "c8c6fe7c34d96b88d6471769aae85063c8045783b2a226fd24f8c573603d17c2" splice_transfer_package_id: "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281" + splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" bridge: package_id: "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72" diff --git a/pkg/config/defaults/config.relayer.mainnet.yaml b/pkg/config/defaults/config.relayer.mainnet.yaml index 00ea56a9..061ca142 100644 --- a/pkg/config/defaults/config.relayer.mainnet.yaml +++ b/pkg/config/defaults/config.relayer.mainnet.yaml @@ -53,6 +53,7 @@ canton: token: cip56_package_id: "${CANTON_CIP56_PACKAGE_ID}" splice_transfer_package_id: "${CANTON_SPLICE_TRANSFER_PACKAGE_ID}" + splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" bridge: package_id: "${CANTON_BRIDGE_PACKAGE_ID}" diff --git a/pkg/config/tests/env-substitution.api.yaml b/pkg/config/tests/env-substitution.api.yaml index 646c395b..34acfac0 100644 --- a/pkg/config/tests/env-substitution.api.yaml +++ b/pkg/config/tests/env-substitution.api.yaml @@ -22,6 +22,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" token: supported_tokens: @@ -29,6 +30,7 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/invalid-database-url.api.yaml b/pkg/config/tests/invalid-database-url.api.yaml index 613d6b78..e522fa70 100644 --- a/pkg/config/tests/invalid-database-url.api.yaml +++ b/pkg/config/tests/invalid-database-url.api.yaml @@ -22,6 +22,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" token: supported_tokens: @@ -29,6 +30,7 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/minimal.api.yaml b/pkg/config/tests/minimal.api.yaml index 7213d3bb..790c45e6 100644 --- a/pkg/config/tests/minimal.api.yaml +++ b/pkg/config/tests/minimal.api.yaml @@ -22,6 +22,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" token: supported_tokens: @@ -29,6 +30,7 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/minimal.relayer.yaml b/pkg/config/tests/minimal.relayer.yaml index bd564e11..c6462f97 100644 --- a/pkg/config/tests/minimal.relayer.yaml +++ b/pkg/config/tests/minimal.relayer.yaml @@ -30,6 +30,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" bridge: max_transfer_amount: "1000" diff --git a/pkg/config/tests/missing-env.api.yaml b/pkg/config/tests/missing-env.api.yaml index aa794b83..77da5d8c 100644 --- a/pkg/config/tests/missing-env.api.yaml +++ b/pkg/config/tests/missing-env.api.yaml @@ -22,6 +22,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" token: supported_tokens: @@ -29,6 +30,7 @@ token: name: "PROMPT" symbol: "PROMPT" decimals: 18 + instrument_id: "PROMPT" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/missing-required.relayer.yaml b/pkg/config/tests/missing-required.relayer.yaml index 088537ee..9b7a226c 100644 --- a/pkg/config/tests/missing-required.relayer.yaml +++ b/pkg/config/tests/missing-required.relayer.yaml @@ -30,6 +30,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" bridge: max_transfer_amount: "1000" diff --git a/pkg/config/tests/monitoring-enabled-no-server.relayer.yaml b/pkg/config/tests/monitoring-enabled-no-server.relayer.yaml index 3e407671..3962dcfd 100644 --- a/pkg/config/tests/monitoring-enabled-no-server.relayer.yaml +++ b/pkg/config/tests/monitoring-enabled-no-server.relayer.yaml @@ -30,6 +30,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" bridge: max_transfer_amount: "1000" diff --git a/pkg/config/tests/monitoring-enabled-with-server.relayer.yaml b/pkg/config/tests/monitoring-enabled-with-server.relayer.yaml index d699a049..4b8e15fd 100644 --- a/pkg/config/tests/monitoring-enabled-with-server.relayer.yaml +++ b/pkg/config/tests/monitoring-enabled-with-server.relayer.yaml @@ -30,6 +30,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" bridge: max_transfer_amount: "1000" diff --git a/pkg/config/tests/unknown-field.relayer.yaml b/pkg/config/tests/unknown-field.relayer.yaml index 063db2bb..3baf8b2b 100644 --- a/pkg/config/tests/unknown-field.relayer.yaml +++ b/pkg/config/tests/unknown-field.relayer.yaml @@ -30,6 +30,7 @@ canton: token: cip56_package_id: "cip56-package" splice_transfer_package_id: "splice-package" + splice_holding_package_id: "test-splice-holding-pkg-id" bridge: max_transfer_amount: "1000" diff --git a/pkg/token/config.go b/pkg/token/config.go index 64a9b1e5..28d238d4 100644 --- a/pkg/token/config.go +++ b/pkg/token/config.go @@ -6,11 +6,13 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// ERC20Token contains ERC-20 token metadata +// ERC20Token contains ERC-20 token metadata and Canton instrument mapping. type ERC20Token struct { - Name string `yaml:"name" validate:"required"` - Symbol string `yaml:"symbol" validate:"required"` - Decimals int `yaml:"decimals" validate:"gte=0,lte=18"` + Name string `yaml:"name" validate:"required"` + Symbol string `yaml:"symbol" validate:"required"` + Decimals int `yaml:"decimals" validate:"gte=0,lte=18"` + InstrumentID string `yaml:"instrument_id" validate:"required"` + InstrumentAdmin string `yaml:"instrument_admin"` // defaults to IssuerParty if empty } // Config holds token metadata indexed by contract address. diff --git a/pkg/token/erc20_test.go b/pkg/token/erc20_test.go index b6bfa92b..ff35e84c 100644 --- a/pkg/token/erc20_test.go +++ b/pkg/token/erc20_test.go @@ -29,8 +29,8 @@ var ( func newCfg() *token.Config { cfg := token.NewConfig("5000000000000000000") // 5 ETH in wei - cfg.AddToken(promptAddr, token.ERC20Token{Name: "Prompt Token", Symbol: "PROMPT", Decimals: 18}) - cfg.AddToken(demoAddr, token.ERC20Token{Name: "Demo Token", Symbol: "DEMO", Decimals: 18}) + cfg.AddToken(promptAddr, token.ERC20Token{Name: "Prompt Token", Symbol: "PROMPT", Decimals: 18, InstrumentID: "PROMPT"}) + cfg.AddToken(demoAddr, token.ERC20Token{Name: "Demo Token", Symbol: "DEMO", Decimals: 18, InstrumentID: "DEMO"}) return cfg } @@ -98,7 +98,7 @@ func TestERC20_Decimals(t *testing.T) { t.Run("decimals value 255 (MaxUint8) is preserved", func(t *testing.T) { cfg := newCfg() addr255 := common.HexToAddress("0x3000000000000000000000000000000000000003") - cfg.AddToken(addr255, token.ERC20Token{Name: "T255", Symbol: "T255", Decimals: 255}) + cfg.AddToken(addr255, token.ERC20Token{Name: "T255", Symbol: "T255", Decimals: 255, InstrumentID: "T255"}) svc := token.NewTokenService(cfg, nil, nil, nil) erc20 := token.NewERC20(addr255, svc) @@ -108,7 +108,7 @@ func TestERC20_Decimals(t *testing.T) { t.Run("decimals value 256 (> MaxUint8) returns zero", func(t *testing.T) { cfg := newCfg() addr256 := common.HexToAddress("0x4000000000000000000000000000000000000004") - cfg.AddToken(addr256, token.ERC20Token{Name: "T256", Symbol: "T256", Decimals: 256}) + cfg.AddToken(addr256, token.ERC20Token{Name: "T256", Symbol: "T256", Decimals: 256, InstrumentID: "T256"}) svc := token.NewTokenService(cfg, nil, nil, nil) erc20 := token.NewERC20(addr256, svc) diff --git a/tests/e2e/devstack/system/system.go b/tests/e2e/devstack/system/system.go index da079c58..611cec8b 100644 --- a/tests/e2e/devstack/system/system.go +++ b/tests/e2e/devstack/system/system.go @@ -43,11 +43,11 @@ type Tokens struct { func NewTokens(manifest *stack.ServiceManifest) *Tokens { return &Tokens{ DEMO: stack.Token{ - ERC20Token: token.ERC20Token{Symbol: "DEMO", Decimals: 18}, + ERC20Token: token.ERC20Token{Symbol: "DEMO", Decimals: 18, InstrumentID: "DEMO"}, Address: common.HexToAddress(manifest.DemoTokenAddr), }, PROMPT: stack.Token{ - ERC20Token: token.ERC20Token{Symbol: "PROMPT", Decimals: 18}, + ERC20Token: token.ERC20Token{Symbol: "PROMPT", Decimals: 18, InstrumentID: "PROMPT"}, Address: common.HexToAddress(manifest.PromptTokenAddr), }, } From 9a382e7291ca3c0733c80c08b2eb980a953720a5 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:29:46 -0600 Subject: [PATCH 2/7] fix: pass stack.Token by pointer in WaitForAPIBalance to fix hugeParam lint --- tests/e2e/devstack/dsl/dsl.go | 2 +- tests/e2e/tests/bridge/deposit_test.go | 6 +++--- tests/e2e/tests/bridge/withdrawal_test.go | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/e2e/devstack/dsl/dsl.go b/tests/e2e/devstack/dsl/dsl.go index 886979fc..255a147d 100644 --- a/tests/e2e/devstack/dsl/dsl.go +++ b/tests/e2e/devstack/dsl/dsl.go @@ -171,7 +171,7 @@ func (d *DSL) Deposit(ctx context.Context, t *testing.T, account stack.Account, // ERC-20 balance of ownerAddr for tok is >= minTokens (human-readable token // amount, e.g. "50"). This is the preferred balance check for api-server tests // — no indexer needed. Pass sys.Tokens.DEMO or sys.Tokens.PROMPT as tok. -func (d *DSL) WaitForAPIBalance(ctx context.Context, t *testing.T, tok stack.Token, ownerAddr common.Address, minTokens string) { +func (d *DSL) WaitForAPIBalance(ctx context.Context, t *testing.T, tok *stack.Token, ownerAddr common.Address, minTokens string) { t.Helper() // Scale minTokens by 10^tok.Decimals. exp := new(big.Int).Exp(big.NewInt(decimalBase), big.NewInt(int64(tok.Decimals)), nil) diff --git a/tests/e2e/tests/bridge/deposit_test.go b/tests/e2e/tests/bridge/deposit_test.go index 2ce1aa76..845c326c 100644 --- a/tests/e2e/tests/bridge/deposit_test.go +++ b/tests/e2e/tests/bridge/deposit_test.go @@ -46,7 +46,7 @@ func TestDeposit_PROMPT_EthereumToCanton(t *testing.T) { // Also verify the balance is reflected through the api-server's /eth JSON-RPC // facade. This exercises the full path: indexer → token service → user store // EVM-address→party lookup → eth_call balanceOf response. - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.PROMPT, account.Address, "1") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.PROMPT, account.Address, "1") } // TestDeposit_SmallAmount_Succeeds verifies that a small PROMPT deposit (0.1 @@ -71,7 +71,7 @@ func TestDeposit_SmallAmount_Succeeds(t *testing.T) { sys.DSL.WaitForRelayerTransfer(ctx, t, txHash.Hex()) sys.DSL.WaitForCantonBalance(ctx, t, regResp.Party, admin, id, "0.1") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.PROMPT, account.Address, "0.1") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.PROMPT, account.Address, "0.1") } // TestDeposit_TwoDeposits_Accumulate verifies that two sequential deposits from @@ -104,5 +104,5 @@ func TestDeposit_TwoDeposits_Accumulate(t *testing.T) { tx2 := sys.DSL.Deposit(ctx, t, account, new(big.Int).Set(one18)) sys.DSL.WaitForRelayerTransfer(ctx, t, tx2.Hex()) sys.DSL.WaitForCantonBalance(ctx, t, regResp.Party, admin, id, "2") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.PROMPT, account.Address, "2") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.PROMPT, account.Address, "2") } diff --git a/tests/e2e/tests/bridge/withdrawal_test.go b/tests/e2e/tests/bridge/withdrawal_test.go index 4de5706d..866d4781 100644 --- a/tests/e2e/tests/bridge/withdrawal_test.go +++ b/tests/e2e/tests/bridge/withdrawal_test.go @@ -56,7 +56,7 @@ func TestWithdrawal_PROMPT_CantonToEthereum(t *testing.T) { // WaitForAPIBalance uses the same indexer data source as WaitForCantonBalance but // exercises the additional api-server path: user-store EVM→party lookup + eth_call facade. sys.DSL.WaitForCantonBalance(ctx, t, regResp.Party, admin, id, "1") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.PROMPT, account.Address, "1") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.PROMPT, account.Address, "1") } // TestWithdrawal_PartialAmount verifies that withdrawing only part of the @@ -92,7 +92,7 @@ func TestWithdrawal_PartialAmount(t *testing.T) { // The remaining 2 PROMPT must be visible both on Canton and via the api-server. sys.DSL.WaitForCantonBalance(ctx, t, regResp.Party, admin, id, "2") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.PROMPT, account.Address, "2") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.PROMPT, account.Address, "2") } // TestWithdrawal_AfterCantonTransfer verifies that a user who received PROMPT @@ -172,5 +172,5 @@ func TestWithdrawal_AfterCantonTransfer(t *testing.T) { // (User1 withdrew their entire PROMPT holding). sys.DSL.WaitForEthBalance(ctx, t, tokenAddr, sys.Accounts.User1.Address, one18) sys.DSL.WaitForCantonBalance(ctx, t, regResp1.Party, admin, id, "0") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.PROMPT, sys.Accounts.User1.Address, "0") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.PROMPT, sys.Accounts.User1.Address, "0") } From 044ecb2cb78860d51a2ba836592dd47af884cbe1 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:32:25 -0600 Subject: [PATCH 3/7] fix: use env vars for mainnet splice_holding_package_id, add test env Address review feedback: - Use ${CANTON_SPLICE_HOLDING_PACKAGE_ID} env var in mainnet configs (consistent with other package ID fields) - Add CANTON_SPLICE_HOLDING_PACKAGE_ID to setDefaultConfigEnv test helper --- pkg/config/config_test.go | 1 + pkg/config/defaults/config.api-server.mainnet.yaml | 2 +- pkg/config/defaults/config.relayer.mainnet.yaml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 560bcf0c..d13d60fb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -409,6 +409,7 @@ func setDefaultConfigEnv(t *testing.T) { t.Setenv("CANTON_IDENTITY_PACKAGE_ID", "identity-package-id") t.Setenv("CANTON_CIP56_PACKAGE_ID", "cip56-package-id") t.Setenv("CANTON_SPLICE_TRANSFER_PACKAGE_ID", "splice-transfer-package-id") + t.Setenv("CANTON_SPLICE_HOLDING_PACKAGE_ID", "splice-holding-package-id") t.Setenv("CANTON_BRIDGE_PACKAGE_ID", "bridge-package-id") t.Setenv("ETHEREUM_RPC_URL", "https://eth.example") t.Setenv("ETHEREUM_WS_URL", "wss://eth.example/ws") diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index d8a36430..19acbb6c 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -39,7 +39,7 @@ canton: token: cip56_package_id: "${CANTON_CIP56_PACKAGE_ID}" splice_transfer_package_id: "${CANTON_SPLICE_TRANSFER_PACKAGE_ID}" - splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" + splice_holding_package_id: "${CANTON_SPLICE_HOLDING_PACKAGE_ID}" bridge: package_id: "${CANTON_BRIDGE_PACKAGE_ID}" diff --git a/pkg/config/defaults/config.relayer.mainnet.yaml b/pkg/config/defaults/config.relayer.mainnet.yaml index 061ca142..c37c53a2 100644 --- a/pkg/config/defaults/config.relayer.mainnet.yaml +++ b/pkg/config/defaults/config.relayer.mainnet.yaml @@ -53,7 +53,7 @@ canton: token: cip56_package_id: "${CANTON_CIP56_PACKAGE_ID}" splice_transfer_package_id: "${CANTON_SPLICE_TRANSFER_PACKAGE_ID}" - splice_holding_package_id: "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" + splice_holding_package_id: "${CANTON_SPLICE_HOLDING_PACKAGE_ID}" bridge: package_id: "${CANTON_BRIDGE_PACKAGE_ID}" From b9a077567e5aae32802fd2c54619b3dcd676ca96 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:33:48 -0600 Subject: [PATCH 4/7] fix: update remaining WaitForAPIBalance callers to pass Token by pointer --- tests/e2e/tests/api/balance_test.go | 2 +- tests/e2e/tests/api/transfer_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/tests/api/balance_test.go b/tests/e2e/tests/api/balance_test.go index 0c1f94e9..877f2993 100644 --- a/tests/e2e/tests/api/balance_test.go +++ b/tests/e2e/tests/api/balance_test.go @@ -44,7 +44,7 @@ func TestGetBalance_AfterMintDEMO(t *testing.T) { mintAmount := "100" sys.DSL.MintDEMO(ctx, t, resp.Party, mintAmount) - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.DEMO, sys.Accounts.User1.Address, mintAmount) + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.DEMO, sys.Accounts.User1.Address, mintAmount) } // TestERC20Balance_AfterDeposit_ReflectsChange verifies that after depositing diff --git a/tests/e2e/tests/api/transfer_test.go b/tests/e2e/tests/api/transfer_test.go index 79ae9662..de31c42b 100644 --- a/tests/e2e/tests/api/transfer_test.go +++ b/tests/e2e/tests/api/transfer_test.go @@ -57,7 +57,7 @@ func TestTransfer_DEMO_BetweenExternalUsers(t *testing.T) { mintAmount := "50" sys.DSL.MintDEMO(ctx, t, resp1.Party, mintAmount) - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.DEMO, sys.Accounts.User1.Address, mintAmount) + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.DEMO, sys.Accounts.User1.Address, mintAmount) transferAmount := "10" prepResp, err := sys.APIServer.PrepareTransfer(ctx, &sys.Accounts.User1, &transfer.PrepareRequest{ @@ -83,7 +83,7 @@ func TestTransfer_DEMO_BetweenExternalUsers(t *testing.T) { t.Fatalf("expected status 'completed', got %q", execResp.Status) } - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.DEMO, sys.Accounts.User2.Address, transferAmount) + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.DEMO, sys.Accounts.User2.Address, transferAmount) } // TestTransfer_CustodialUser_PrepareRejects verifies that calling PrepareTransfer @@ -175,7 +175,7 @@ func TestTransfer_InvalidSignature_Fails(t *testing.T) { _, _ = sys.DSL.RegisterExternalUser(ctx, t, sys.Accounts.User2) sys.DSL.MintDEMO(ctx, t, resp1.Party, "10") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.DEMO, sys.Accounts.User1.Address, "10") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.DEMO, sys.Accounts.User1.Address, "10") prepResp, err := sys.APIServer.PrepareTransfer(ctx, &sys.Accounts.User1, &transfer.PrepareRequest{ To: sys.Accounts.User2.Address.Hex(), @@ -221,7 +221,7 @@ func TestTransfer_InsufficientBalance_Fails(t *testing.T) { // Mint a small amount. sys.DSL.MintDEMO(ctx, t, resp1.Party, "1") - sys.DSL.WaitForAPIBalance(ctx, t, sys.Tokens.DEMO, sys.Accounts.User1.Address, "1") + sys.DSL.WaitForAPIBalance(ctx, t, &sys.Tokens.DEMO, sys.Accounts.User1.Address, "1") // Try to transfer more than the minted balance. _, err := sys.APIServer.PrepareTransfer(ctx, &sys.Accounts.User1, &transfer.PrepareRequest{ From b5cb87018280c8f78ba8145bdc0e4526e15c0a94 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:51:08 -0600 Subject: [PATCH 5/7] feat: unified balance query via Splice HoldingV1 interface (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: unified balance query via Splice HoldingV1 interface Add GetHoldingsByParty method that queries Canton using the Splice HoldingV1 interface as the user's own party, instead of querying CIP56Holding template as IssuerParty. This enables visibility of tokens issued by third parties (e.g., USDCx from Circle) where our IssuerParty is not a stakeholder on the holdings. GetHoldings now delegates to GetHoldingsByParty. GetAllHoldings retains the IssuerParty/template path for totalSupply and indexer use. Closes #218 * docs: clarify that CreateArguments is always populated for interface queries Canton Ledger API v2 proto marks create_arguments as Required on CreatedEvent, so decodeHolding works identically for both template-based and interface-based queries. Add comment to address review concern. * chore: add TODO for GetHoldingsByParty unit tests * feat: add USDCx to supported tokens config (#229) * feat: add USDCx to supported tokens config Add USDCx (USD Coin, 6 decimals) to supported_tokens in all config YAML files. This enables MetaMask to query balanceOf, symbol, decimals, and name for USDCx through the ERC-20 facade. USDCx is automatically added to the non-custodial transfer allowlist since it is derived from supported_tokens. Mainnet config uses ${CANTON_USDCX_INSTRUMENT_ADMIN} env var for the instrument admin (Circle's Bridge-Operator party). Closes #213 * feat: integrate Transfer Factory Registry API for external token transfers (#230) Add support for transferring tokens issued by external parties (e.g., USDCx from Circle) by routing factory discovery based on InstrumentAdmin: - Local tokens (InstrumentAdmin == IssuerParty): existing ACS query - External tokens: POST to Transfer Factory Registry API Key changes: - New RegistryClient (HTTP POST) calls Splice Transfer Factory Registry and parses factoryId, choiceContext, disclosedContracts from response - resolveTransferFactory routes by InstrumentAdmin automatically - DisclosedContracts from registry response threaded through PrepareSubmissionRequest for both custodial and non-custodial paths - EncodeExtraArgs now accepts choiceContext from registry response - ReadAs conditionally set: IssuerParty for local, empty for external - ExternalTokens config maps InstrumentAdmin → registry URL - WithRegistryClient option on token client constructor No breaking changes to public API (TransferByPartyID, PrepareTransfer). Routing is internal based on holdings data. Closes #214 --- config.e2e-local.yaml | 5 + pkg/cantonsdk/client/client.go | 7 + pkg/cantonsdk/token/client.go | 242 +++++++++++++----- pkg/cantonsdk/token/config.go | 11 + pkg/cantonsdk/token/encode.go | 3 +- pkg/cantonsdk/token/options.go | 11 +- pkg/cantonsdk/token/registry_client.go | 145 +++++++++++ pkg/cantonsdk/values/meta.go | 28 +- pkg/config/config_test.go | 1 + .../defaults/config.api-server.docker.yaml | 5 + .../config.api-server.local-devnet.yaml | 6 + .../defaults/config.api-server.mainnet.yaml | 6 + pkg/config/tests/env-substitution.api.yaml | 5 + .../tests/invalid-database-url.api.yaml | 5 + pkg/config/tests/minimal.api.yaml | 5 + pkg/config/tests/missing-env.api.yaml | 5 + pkg/token/mocks/mock_canton_token.go | 60 +++++ pkg/transfer/mocks/mock_canton_token.go | 60 +++++ 18 files changed, 524 insertions(+), 86 deletions(-) create mode 100644 pkg/cantonsdk/token/registry_client.go diff --git a/config.e2e-local.yaml b/config.e2e-local.yaml index 25575da9..28c724cd 100644 --- a/config.e2e-local.yaml +++ b/config.e2e-local.yaml @@ -82,6 +82,11 @@ token: symbol: "DEMO" decimals: 18 instrument_id: "DEMO" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/cantonsdk/client/client.go b/pkg/cantonsdk/client/client.go index 3d1f02f6..06448fac 100644 --- a/pkg/cantonsdk/client/client.go +++ b/pkg/cantonsdk/client/client.go @@ -7,6 +7,8 @@ package client import ( "context" "fmt" + "net/http" + "time" "github.com/chainsafe/canton-middleware/pkg/cantonsdk/bridge" "github.com/chainsafe/canton-middleware/pkg/cantonsdk/identity" @@ -60,6 +62,11 @@ func New(ctx context.Context, cfg *Config, opts ...Option) (*Client, error) { if s.keyResolver != nil { tokenOpts = append(tokenOpts, token.WithKeyResolver(s.keyResolver)) } + if len(cfg.Token.ExternalTokens) > 0 { + tokenOpts = append(tokenOpts, token.WithRegistryClient( + token.NewRegistryClient(&http.Client{Timeout: 10 * time.Second}), + )) + } tk, err := token.New(cfg.Token, l, id, tokenOpts...) if err != nil { _ = l.Close() diff --git a/pkg/cantonsdk/token/client.go b/pkg/cantonsdk/token/client.go index 8adf9d89..11071688 100644 --- a/pkg/cantonsdk/token/client.go +++ b/pkg/cantonsdk/token/client.go @@ -38,6 +38,9 @@ const ( spliceTransferModule = "Splice.Api.Token.TransferInstructionV1" spliceTransferFactory = "TransferFactory" + + spliceHoldingModule = "Splice.Api.Token.HoldingV1" + spliceHoldingEntity = "Holding" ) // Token defines CIP-56 token operations. @@ -51,10 +54,17 @@ type Token interface { // Burn burns tokens using TokenConfig.IssuerBurn. Burn(ctx context.Context, req *BurnRequest) error - // GetHoldings returns all CIP56Holding contracts for the owner and token symbol. + // GetHoldings returns holdings for the owner and token symbol. + // Delegates to GetHoldingsByParty using the Splice HoldingV1 interface. GetHoldings(ctx context.Context, ownerParty string, tokenSymbol string) ([]*Holding, error) - // GetAllHoldings GetHoldings returns all CIP56Holding contracts. + // GetHoldingsByParty queries all Splice HoldingV1 holdings visible to the given party, + // optionally filtered by instrumentID (empty string returns all instruments). + // This is the unified query path for all Splice-compliant tokens (CIP-56 and external). + GetHoldingsByParty(ctx context.Context, ownerParty, instrumentID string) ([]*Holding, error) + + // GetAllHoldings returns all CIP56Holding contracts queried as IssuerParty. + // Used by the indexer and totalSupply — does NOT use the unified HoldingV1 path. GetAllHoldings(ctx context.Context) ([]*Holding, error) // TODO: use pagination // GetBalanceByFingerprint returns the owner's total balance (sum of holdings) for the token symbol. @@ -91,11 +101,12 @@ type Token interface { // Client implements CIP-56 token operations. type Client struct { - cfg *Config - ledger ledger.Ledger - identity identity.Identity - keyResolver KeyResolver - logger *zap.Logger + cfg *Config + ledger ledger.Ledger + identity identity.Identity + keyResolver KeyResolver + registryClient *RegistryClient + logger *zap.Logger } // New creates a new token client. @@ -113,11 +124,12 @@ func New(cfg *Config, l ledger.Ledger, id identity.Identity, opts ...Option) (*C s := applyOptions(opts) return &Client{ - cfg: cfg, - ledger: l, - identity: id, - keyResolver: s.keyResolver, - logger: s.logger, + cfg: cfg, + ledger: l, + identity: id, + keyResolver: s.keyResolver, + registryClient: s.registryClient, + logger: s.logger, }, nil } @@ -259,21 +271,52 @@ func (c *Client) GetHoldings(ctx context.Context, ownerParty string, tokenSymbol if tokenSymbol == "" { return nil, fmt.Errorf("token symbol is required") } + return c.GetHoldingsByParty(ctx, ownerParty, tokenSymbol) +} - allHoldings, err := c.GetAllHoldings(ctx) +// GetHoldingsByParty queries all Splice HoldingV1 holdings visible to the given party. +// This is the unified query path for all Splice-compliant tokens (CIP-56 and external like USDCx). +// If instrumentID is non-empty, results are filtered to that instrument. +// TODO: add unit tests with mocked ledger client (happy path, filtering, errors). +func (c *Client) GetHoldingsByParty(ctx context.Context, ownerParty, instrumentID string) ([]*Holding, error) { + if ownerParty == "" { + return nil, fmt.Errorf("owner party is required") + } + + end, err := c.ledger.GetLedgerEnd(ctx) if err != nil { return nil, err } + if end == 0 { + return []*Holding{}, nil + } + + iid := &lapiv2.Identifier{ + PackageId: c.cfg.SpliceHoldingPackageID, + ModuleName: spliceHoldingModule, + EntityName: spliceHoldingEntity, + } - validHoldings := make([]*Holding, 0) - for _, h := range allHoldings { - if h.Owner != ownerParty || h.Symbol != tokenSymbol { + // GetActiveContractsByInterface returns CreatedEvents with create_arguments populated + // (Required field per Canton Ledger API v2 proto), so decodeHolding works identically + // for both template-based and interface-based queries. + events, err := c.ledger.GetActiveContractsByInterface(ctx, end, []string{ownerParty}, iid) + if err != nil { + return nil, fmt.Errorf("query holdings by party: %w", err) + } + + out := make([]*Holding, 0, len(events)) + for _, ce := range events { + h := decodeHolding(ce) + if h.Owner != ownerParty { + continue + } + if instrumentID != "" && h.InstrumentID != instrumentID { continue } - validHoldings = append(validHoldings, h) + out = append(out, h) } - - return validHoldings, nil + return out, nil } func (c *Client) GetAllHoldings(ctx context.Context) ([]*Holding, error) { @@ -407,12 +450,7 @@ func (c *Client) TransferByPartyID(ctx context.Context, idempotencyKey, fromPart return fmt.Errorf("select holdings for transfer: %w", err) } - factoryCID, err := c.getTransferFactoryCID(ctx) - if err != nil { - return err - } - - return c.transferViaFactory(ctx, &transferFactoryRequest{ + req := &transferFactoryRequest{ CommandID: idempotencyKey, FromPartyID: fromParty, ToPartyID: toParty, @@ -420,19 +458,27 @@ func (c *Client) TransferByPartyID(ctx context.Context, idempotencyKey, fromPart InstrumentAdmin: selected.InstrumentAdmin, InstrumentID: selected.InstrumentID, InputHoldingCIDs: selected.CIDs, - FactoryCID: factoryCID, - }) + } + + if err := c.resolveTransferFactory(ctx, req); err != nil { + return err + } + + return c.transferViaFactory(ctx, req) } type transferFactoryRequest struct { - CommandID string - FromPartyID string - ToPartyID string - Amount string - InstrumentAdmin string - InstrumentID string - InputHoldingCIDs []string - FactoryCID string + CommandID string + FromPartyID string + ToPartyID string + Amount string + InstrumentAdmin string + InstrumentID string + InputHoldingCIDs []string + FactoryCID string + ChoiceContext map[string]string + DisclosedContracts []*lapiv2.DisclosedContract + IsExternal bool } func (c *Client) transferViaFactory(ctx context.Context, req *transferFactoryRequest) error { @@ -447,13 +493,19 @@ func (c *Client) transferViaFactory(ctx context.Context, req *transferFactoryReq cmd := c.buildTransferCommand(req) + readAs := []string{c.cfg.IssuerParty} + if req.IsExternal { + readAs = nil + } + commands := &lapiv2.Commands{ - SynchronizerId: c.cfg.DomainID, - CommandId: req.CommandID, - UserId: c.cfg.UserID, - ActAs: []string{req.FromPartyID}, - ReadAs: []string{c.cfg.IssuerParty}, - Commands: []*lapiv2.Command{cmd}, + SynchronizerId: c.cfg.DomainID, + CommandId: req.CommandID, + UserId: c.cfg.UserID, + ActAs: []string{req.FromPartyID}, + ReadAs: readAs, + Commands: []*lapiv2.Command{cmd}, + DisclosedContracts: req.DisclosedContracts, } return c.prepareAndExecuteAsUser(ctx, commands, signerKey, req.FromPartyID) @@ -486,6 +538,7 @@ func (c *Client) buildTransferCommand(req *transferFactoryRequest) *lapiv2.Comma now, now.Add(defaultTransferValidity), req.InputHoldingCIDs, + req.ChoiceContext, ), }, }, @@ -502,6 +555,58 @@ func (c *Client) getTransferFactoryCID(ctx context.Context) (string, error) { return info.ContractID, nil } +// resolveTransferFactory fills in factory info on the request by routing based on InstrumentAdmin. +// For our tokens (InstrumentAdmin == IssuerParty): uses local ACS query. +// For external tokens: calls the Transfer Factory Registry API. +func (c *Client) resolveTransferFactory(ctx context.Context, req *transferFactoryRequest) error { + if req.InstrumentAdmin == c.cfg.IssuerParty { + cid, err := c.getTransferFactoryCID(ctx) + if err != nil { + return err + } + req.FactoryCID = cid + return nil + } + + // External token — use registry + if c.registryClient == nil { + return fmt.Errorf("no registry client configured for external token transfers") + } + extCfg, ok := c.cfg.ExternalTokens[req.InstrumentAdmin] + if !ok { + return fmt.Errorf("unsupported external token issuer: %s", req.InstrumentAdmin) + } + + regResp, err := c.registryClient.GetTransferFactory(ctx, extCfg.RegistryURL, &RegistryRequest{ + ExpectedAdmin: req.InstrumentAdmin, + Transfer: RegistryTransferDetail{ + Sender: req.FromPartyID, + Receiver: req.ToPartyID, + Amount: req.Amount, + InstrumentID: req.InstrumentID, + InputHoldingCIDs: req.InputHoldingCIDs, + }, + }) + if err != nil { + return fmt.Errorf("registry lookup for %s: %w", req.InstrumentAdmin, err) + } + + req.FactoryCID = regResp.FactoryID + req.IsExternal = true + + req.ChoiceContext, err = ConvertChoiceContext(regResp.ChoiceContext) + if err != nil { + return fmt.Errorf("convert choice context: %w", err) + } + + req.DisclosedContracts, err = ConvertDisclosedContracts(regResp.DisclosedContracts, c.cfg.DomainID) + if err != nil { + return fmt.Errorf("convert disclosed contracts: %w", err) + } + + return nil +} + func (c *Client) GetTransferFactory(ctx context.Context) (*TransferFactoryInfo, error) { end, err := c.ledger.GetLedgerEnd(ctx) if err != nil { @@ -587,12 +692,13 @@ func (c *Client) prepareAndExecuteAsUser(ctx context.Context, commands *lapiv2.C authCtx := c.ledger.AuthContext(ctx) prepResp, err := c.ledger.Interactive().PrepareSubmission(authCtx, &interactivev2.PrepareSubmissionRequest{ - UserId: commands.UserId, - CommandId: commands.CommandId, - Commands: commands.Commands, - ActAs: commands.ActAs, - ReadAs: commands.ReadAs, - SynchronizerId: commands.SynchronizerId, + UserId: commands.UserId, + CommandId: commands.CommandId, + Commands: commands.Commands, + ActAs: commands.ActAs, + ReadAs: commands.ReadAs, + SynchronizerId: commands.SynchronizerId, + DisclosedContracts: commands.DisclosedContracts, }) if err != nil { return fmt.Errorf("prepare submission: %w", err) @@ -698,11 +804,6 @@ func (c *Client) PrepareTransfer(ctx context.Context, req *PrepareTransferReques return nil, fmt.Errorf("select holdings for transfer: %w", err) } - factoryCID, err := c.getTransferFactoryCID(ctx) - if err != nil { - return nil, err - } - factoryReq := &transferFactoryRequest{ FromPartyID: req.FromPartyID, ToPartyID: req.ToPartyID, @@ -710,27 +811,38 @@ func (c *Client) PrepareTransfer(ctx context.Context, req *PrepareTransferReques InstrumentAdmin: selected.InstrumentAdmin, InstrumentID: selected.InstrumentID, InputHoldingCIDs: selected.CIDs, - FactoryCID: factoryCID, } + + if resolveErr := c.resolveTransferFactory(ctx, factoryReq); resolveErr != nil { + return nil, resolveErr + } + cmd := c.buildTransferCommand(factoryReq) + readAs := []string{c.cfg.IssuerParty} + if factoryReq.IsExternal { + readAs = nil + } + commands := &lapiv2.Commands{ - SynchronizerId: c.cfg.DomainID, - CommandId: uuid.NewString(), - UserId: c.cfg.UserID, - ActAs: []string{req.FromPartyID}, - ReadAs: []string{c.cfg.IssuerParty}, - Commands: []*lapiv2.Command{cmd}, + SynchronizerId: c.cfg.DomainID, + CommandId: uuid.NewString(), + UserId: c.cfg.UserID, + ActAs: []string{req.FromPartyID}, + ReadAs: readAs, + Commands: []*lapiv2.Command{cmd}, + DisclosedContracts: factoryReq.DisclosedContracts, } authCtx := c.ledger.AuthContext(ctx) prepResp, err := c.ledger.Interactive().PrepareSubmission(authCtx, &interactivev2.PrepareSubmissionRequest{ - UserId: commands.UserId, - CommandId: commands.CommandId, - Commands: commands.Commands, - ActAs: commands.ActAs, - ReadAs: commands.ReadAs, - SynchronizerId: commands.SynchronizerId, + UserId: commands.UserId, + CommandId: commands.CommandId, + Commands: commands.Commands, + ActAs: commands.ActAs, + ReadAs: commands.ReadAs, + SynchronizerId: commands.SynchronizerId, + DisclosedContracts: commands.DisclosedContracts, }) if err != nil { return nil, fmt.Errorf("prepare submission: %w", err) diff --git a/pkg/cantonsdk/token/config.go b/pkg/cantonsdk/token/config.go index 9397ce3f..a93db492 100644 --- a/pkg/cantonsdk/token/config.go +++ b/pkg/cantonsdk/token/config.go @@ -2,6 +2,12 @@ package token import "errors" +// ExternalTokenConfig holds the registry endpoint for an external token issuer. +// Key in the map is the InstrumentAdmin party ID (e.g., Circle's Bridge-Operator). +type ExternalTokenConfig struct { + RegistryURL string `yaml:"registry_url" validate:"required"` +} + // Config contains the configuration required to initialize the token client. type Config struct { DomainID string `yaml:"domain_id"` @@ -11,6 +17,11 @@ type Config struct { CIP56PackageID string `yaml:"cip56_package_id" validate:"required"` SpliceTransferPackageID string `yaml:"splice_transfer_package_id" validate:"required"` SpliceHoldingPackageID string `yaml:"splice_holding_package_id" validate:"required"` + + // ExternalTokens maps InstrumentAdmin party IDs to their registry configuration. + // Tokens whose InstrumentAdmin matches IssuerParty use local ACS-based factory discovery. + // Tokens whose InstrumentAdmin is in this map use the external registry API. + ExternalTokens map[string]ExternalTokenConfig `yaml:"external_tokens"` } func (c *Config) validate() error { diff --git a/pkg/cantonsdk/token/encode.go b/pkg/cantonsdk/token/encode.go index 957d7b66..3fc9d581 100644 --- a/pkg/cantonsdk/token/encode.go +++ b/pkg/cantonsdk/token/encode.go @@ -41,6 +41,7 @@ func encodeTransferFactoryTransferArgs( requestedAt time.Time, executeBefore time.Time, inputHoldingCIDs []string, + choiceContext map[string]string, ) *lapiv2.Record { holdingCidValues := make([]*lapiv2.Value, len(inputHoldingCIDs)) for i, cid := range inputHoldingCIDs { @@ -68,7 +69,7 @@ func encodeTransferFactoryTransferArgs( Fields: []*lapiv2.RecordField{ {Label: "expectedAdmin", Value: values.PartyValue(expectedAdmin)}, {Label: "transfer", Value: transfer}, - {Label: "extraArgs", Value: values.EncodeExtraArgs()}, + {Label: "extraArgs", Value: values.EncodeExtraArgs(choiceContext)}, }, } } diff --git a/pkg/cantonsdk/token/options.go b/pkg/cantonsdk/token/options.go index e03e139d..624a603c 100644 --- a/pkg/cantonsdk/token/options.go +++ b/pkg/cantonsdk/token/options.go @@ -3,8 +3,9 @@ package token import "go.uber.org/zap" type settings struct { - logger *zap.Logger - keyResolver KeyResolver + logger *zap.Logger + keyResolver KeyResolver + registryClient *RegistryClient } // Option configures the token client. @@ -21,6 +22,12 @@ func WithKeyResolver(kr KeyResolver) Option { return func(s *settings) { s.keyResolver = kr } } +// WithRegistryClient sets the HTTP client for external Transfer Factory Registry API calls. +// Required for transferring tokens issued by external parties (e.g., USDCx). +func WithRegistryClient(rc *RegistryClient) Option { + return func(s *settings) { s.registryClient = rc } +} + func applyOptions(opts []Option) settings { s := settings{logger: zap.NewNop()} for _, opt := range opts { diff --git a/pkg/cantonsdk/token/registry_client.go b/pkg/cantonsdk/token/registry_client.go new file mode 100644 index 00000000..b6a57ec8 --- /dev/null +++ b/pkg/cantonsdk/token/registry_client.go @@ -0,0 +1,145 @@ +package token + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + lapiv2 "github.com/chainsafe/canton-middleware/pkg/cantonsdk/lapi/v2" +) + +const registryPath = "/registry/transfer-instruction/v1/transfer-factory" + +// RegistryClient calls the Splice Transfer Factory Registry API to discover +// transfer factories for external tokens (e.g., USDCx). +type RegistryClient struct { + httpClient *http.Client +} + +// NewRegistryClient creates a new registry client. +func NewRegistryClient(httpClient *http.Client) *RegistryClient { + if httpClient == nil { + httpClient = &http.Client{} + } + return &RegistryClient{httpClient: httpClient} +} + +// RegistryRequest is the POST body for the Transfer Factory Registry API. +type RegistryRequest struct { + ExpectedAdmin string `json:"expectedAdmin"` + Transfer RegistryTransferDetail `json:"transfer"` + ExtraArgs map[string]any `json:"extraArgs,omitempty"` +} + +// RegistryTransferDetail contains the transfer parameters for registry lookup. +type RegistryTransferDetail struct { + Sender string `json:"sender"` + Receiver string `json:"receiver"` + Amount string `json:"amount"` + InstrumentID string `json:"instrumentId"` + InputHoldingCIDs []string `json:"inputHoldingCids"` +} + +// RegistryResponse is the response from the Transfer Factory Registry API. +type RegistryResponse struct { + FactoryID string `json:"factoryId"` + TransferKind string `json:"transferKind"` + ChoiceContext json.RawMessage `json:"choiceContext"` + DisclosedContracts json.RawMessage `json:"disclosedContracts"` +} + +// registryDisclosedContract is the JSON shape of a disclosed contract from the registry. +type registryDisclosedContract struct { + ContractID string `json:"contractId"` + CreatedEventBlob string `json:"createdEventBlob"` // base64 + TemplateID string `json:"templateId"` + SynchronizerID string `json:"synchronizerId"` +} + +// GetTransferFactory calls the registry to discover the transfer factory for an external token. +func (rc *RegistryClient) GetTransferFactory(ctx context.Context, registryBaseURL string, req *RegistryRequest) (*RegistryResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal registry request: %w", err) + } + + url := strings.TrimRight(registryBaseURL, "/") + registryPath + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create registry request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := rc.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("registry request failed: %w", err) + } + defer resp.Body.Close() + + const maxResponseBytes = 1 << 20 // 1 MB + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("read registry response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result RegistryResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse registry response: %w", err) + } + + return &result, nil +} + +// ConvertDisclosedContracts parses the registry's disclosed contracts JSON into proto messages. +func ConvertDisclosedContracts(raw json.RawMessage, fallbackDomainID string) ([]*lapiv2.DisclosedContract, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + + var contracts []registryDisclosedContract + if err := json.Unmarshal(raw, &contracts); err != nil { + return nil, fmt.Errorf("parse disclosed contracts: %w", err) + } + + out := make([]*lapiv2.DisclosedContract, 0, len(contracts)) + for _, c := range contracts { + blob, err := base64.StdEncoding.DecodeString(c.CreatedEventBlob) + if err != nil { + return nil, fmt.Errorf("decode created_event_blob for %s: %w", c.ContractID, err) + } + + domainID := c.SynchronizerID + if domainID == "" { + domainID = fallbackDomainID + } + + out = append(out, &lapiv2.DisclosedContract{ + ContractId: c.ContractID, + CreatedEventBlob: blob, + SynchronizerId: domainID, + }) + } + return out, nil +} + +// ConvertChoiceContext parses the registry's choice context JSON into a map suitable for EncodeExtraArgs. +func ConvertChoiceContext(raw json.RawMessage) (map[string]string, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("parse choice context: %w", err) + } + return m, nil +} diff --git a/pkg/cantonsdk/values/meta.go b/pkg/cantonsdk/values/meta.go index 2942c49d..3a4fcc04 100644 --- a/pkg/cantonsdk/values/meta.go +++ b/pkg/cantonsdk/values/meta.go @@ -104,30 +104,22 @@ func EncodeInstrumentId(admin, id string) *lapiv2.Value { } } -// EncodeExtraArgs creates a Splice ExtraArgs { context: ChoiceContext { values: {} }, meta: Metadata { values: {} } } value. -func EncodeExtraArgs() *lapiv2.Value { - emptyChoiceContext := &lapiv2.Value{ - Sum: &lapiv2.Value_Record{ - Record: &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - { - Label: "values", - Value: &lapiv2.Value{ - Sum: &lapiv2.Value_TextMap{ - TextMap: &lapiv2.TextMap{Entries: []*lapiv2.TextMap_Entry{}}, - }, - }, - }, - }, - }, - }, +// EncodeExtraArgs creates a Splice ExtraArgs { context: ChoiceContext { values: TextMap }, meta: Metadata { values: TextMap } } value. +// If choiceContext is nil or empty, the context field contains an empty TextMap (default behavior). +// If choiceContext is populated (e.g., from a Transfer Factory Registry response), its entries are encoded. +func EncodeExtraArgs(choiceContext map[string]string) *lapiv2.Value { + var contextValue *lapiv2.Value + if len(choiceContext) > 0 { + contextValue = EncodeMetadata(choiceContext) + } else { + contextValue = EmptyMetadata() } return &lapiv2.Value{ Sum: &lapiv2.Value_Record{ Record: &lapiv2.Record{ Fields: []*lapiv2.RecordField{ - {Label: "context", Value: emptyChoiceContext}, + {Label: "context", Value: contextValue}, {Label: "meta", Value: EmptyMetadata()}, }, }, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d13d60fb..e66f43b6 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -411,6 +411,7 @@ func setDefaultConfigEnv(t *testing.T) { t.Setenv("CANTON_SPLICE_TRANSFER_PACKAGE_ID", "splice-transfer-package-id") t.Setenv("CANTON_SPLICE_HOLDING_PACKAGE_ID", "splice-holding-package-id") t.Setenv("CANTON_BRIDGE_PACKAGE_ID", "bridge-package-id") + t.Setenv("CANTON_USDCX_INSTRUMENT_ADMIN", "Bridge-Operator::1220test") t.Setenv("ETHEREUM_RPC_URL", "https://eth.example") t.Setenv("ETHEREUM_WS_URL", "wss://eth.example/ws") t.Setenv("ETHEREUM_CHAIN_ID", "1") diff --git a/pkg/config/defaults/config.api-server.docker.yaml b/pkg/config/defaults/config.api-server.docker.yaml index b9a3a8d2..f7fdfeba 100644 --- a/pkg/config/defaults/config.api-server.docker.yaml +++ b/pkg/config/defaults/config.api-server.docker.yaml @@ -73,6 +73,11 @@ token: symbol: "DEMO" decimals: 18 instrument_id: "DEMO" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/config/defaults/config.api-server.local-devnet.yaml b/pkg/config/defaults/config.api-server.local-devnet.yaml index e1f3d9cb..5ba29195 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -59,6 +59,12 @@ token: symbol: "DEMO" decimals: 18 instrument_id: "DEMO" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" + instrument_admin: "" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index 19acbb6c..fa939da9 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -52,6 +52,12 @@ token: symbol: "DEMO" decimals: 18 instrument_id: "DEMO" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" + instrument_admin: "${CANTON_USDCX_INSTRUMENT_ADMIN}" native_balance_wei: "1000000000000000000000" eth_rpc: diff --git a/pkg/config/tests/env-substitution.api.yaml b/pkg/config/tests/env-substitution.api.yaml index 34acfac0..ccc220fc 100644 --- a/pkg/config/tests/env-substitution.api.yaml +++ b/pkg/config/tests/env-substitution.api.yaml @@ -31,6 +31,11 @@ token: symbol: "PROMPT" decimals: 18 instrument_id: "PROMPT" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/invalid-database-url.api.yaml b/pkg/config/tests/invalid-database-url.api.yaml index e522fa70..47f10408 100644 --- a/pkg/config/tests/invalid-database-url.api.yaml +++ b/pkg/config/tests/invalid-database-url.api.yaml @@ -31,6 +31,11 @@ token: symbol: "PROMPT" decimals: 18 instrument_id: "PROMPT" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/minimal.api.yaml b/pkg/config/tests/minimal.api.yaml index 790c45e6..17c043a7 100644 --- a/pkg/config/tests/minimal.api.yaml +++ b/pkg/config/tests/minimal.api.yaml @@ -31,6 +31,11 @@ token: symbol: "PROMPT" decimals: 18 instrument_id: "PROMPT" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" eth_rpc: chain_id: 31337 diff --git a/pkg/config/tests/missing-env.api.yaml b/pkg/config/tests/missing-env.api.yaml index 77da5d8c..ee39fb6f 100644 --- a/pkg/config/tests/missing-env.api.yaml +++ b/pkg/config/tests/missing-env.api.yaml @@ -31,6 +31,11 @@ token: symbol: "PROMPT" decimals: 18 instrument_id: "PROMPT" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": + name: "USD Coin" + symbol: "USDCx" + decimals: 6 + instrument_id: "USDCx" eth_rpc: chain_id: 31337 diff --git a/pkg/token/mocks/mock_canton_token.go b/pkg/token/mocks/mock_canton_token.go index db4b29a7..20c54581 100644 --- a/pkg/token/mocks/mock_canton_token.go +++ b/pkg/token/mocks/mock_canton_token.go @@ -350,6 +350,66 @@ func (_c *Token_GetHoldings_Call) RunAndReturn(run func(context.Context, string, return _c } +// GetHoldingsByParty provides a mock function with given fields: ctx, ownerParty, instrumentID +func (_m *Token) GetHoldingsByParty(ctx context.Context, ownerParty string, instrumentID string) ([]*token.Holding, error) { + ret := _m.Called(ctx, ownerParty, instrumentID) + + if len(ret) == 0 { + panic("no return value specified for GetHoldingsByParty") + } + + var r0 []*token.Holding + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]*token.Holding, error)); ok { + return rf(ctx, ownerParty, instrumentID) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []*token.Holding); ok { + r0 = rf(ctx, ownerParty, instrumentID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*token.Holding) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, ownerParty, instrumentID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Token_GetHoldingsByParty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHoldingsByParty' +type Token_GetHoldingsByParty_Call struct { + *mock.Call +} + +// GetHoldingsByParty is a helper method to define mock.On call +// - ctx context.Context +// - ownerParty string +// - instrumentID string +func (_e *Token_Expecter) GetHoldingsByParty(ctx interface{}, ownerParty interface{}, instrumentID interface{}) *Token_GetHoldingsByParty_Call { + return &Token_GetHoldingsByParty_Call{Call: _e.mock.On("GetHoldingsByParty", ctx, ownerParty, instrumentID)} +} + +func (_c *Token_GetHoldingsByParty_Call) Run(run func(ctx context.Context, ownerParty string, instrumentID string)) *Token_GetHoldingsByParty_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *Token_GetHoldingsByParty_Call) Return(_a0 []*token.Holding, _a1 error) *Token_GetHoldingsByParty_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Token_GetHoldingsByParty_Call) RunAndReturn(run func(context.Context, string, string) ([]*token.Holding, error)) *Token_GetHoldingsByParty_Call { + _c.Call.Return(run) + return _c +} + // GetTokenConfigCID provides a mock function with given fields: ctx, tokenSymbol func (_m *Token) GetTokenConfigCID(ctx context.Context, tokenSymbol string) (string, error) { ret := _m.Called(ctx, tokenSymbol) diff --git a/pkg/transfer/mocks/mock_canton_token.go b/pkg/transfer/mocks/mock_canton_token.go index db4b29a7..20c54581 100644 --- a/pkg/transfer/mocks/mock_canton_token.go +++ b/pkg/transfer/mocks/mock_canton_token.go @@ -350,6 +350,66 @@ func (_c *Token_GetHoldings_Call) RunAndReturn(run func(context.Context, string, return _c } +// GetHoldingsByParty provides a mock function with given fields: ctx, ownerParty, instrumentID +func (_m *Token) GetHoldingsByParty(ctx context.Context, ownerParty string, instrumentID string) ([]*token.Holding, error) { + ret := _m.Called(ctx, ownerParty, instrumentID) + + if len(ret) == 0 { + panic("no return value specified for GetHoldingsByParty") + } + + var r0 []*token.Holding + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]*token.Holding, error)); ok { + return rf(ctx, ownerParty, instrumentID) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []*token.Holding); ok { + r0 = rf(ctx, ownerParty, instrumentID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*token.Holding) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, ownerParty, instrumentID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Token_GetHoldingsByParty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHoldingsByParty' +type Token_GetHoldingsByParty_Call struct { + *mock.Call +} + +// GetHoldingsByParty is a helper method to define mock.On call +// - ctx context.Context +// - ownerParty string +// - instrumentID string +func (_e *Token_Expecter) GetHoldingsByParty(ctx interface{}, ownerParty interface{}, instrumentID interface{}) *Token_GetHoldingsByParty_Call { + return &Token_GetHoldingsByParty_Call{Call: _e.mock.On("GetHoldingsByParty", ctx, ownerParty, instrumentID)} +} + +func (_c *Token_GetHoldingsByParty_Call) Run(run func(ctx context.Context, ownerParty string, instrumentID string)) *Token_GetHoldingsByParty_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *Token_GetHoldingsByParty_Call) Return(_a0 []*token.Holding, _a1 error) *Token_GetHoldingsByParty_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Token_GetHoldingsByParty_Call) RunAndReturn(run func(context.Context, string, string) ([]*token.Holding, error)) *Token_GetHoldingsByParty_Call { + _c.Call.Return(run) + return _c +} + // GetTokenConfigCID provides a mock function with given fields: ctx, tokenSymbol func (_m *Token) GetTokenConfigCID(ctx context.Context, tokenSymbol string) (string, error) { ret := _m.Called(ctx, tokenSymbol) From ba4313d90104635bb4a0b83cdac42a6bc6576564 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:04:35 -0600 Subject: [PATCH 6/7] remove unused InstrumentAdmin field from ERC20Token The field was never read anywhere in the codebase. Transfer-factory routing uses InstrumentAdmin from the on-chain holding contract, not config. External tokens are handled via cantonsdk/token.Config.ExternalTokens keyed by admin party ID after #228. Removes the field, the instrument_admin yaml keys in local-devnet and mainnet configs, and the CANTON_USDCX_INSTRUMENT_ADMIN env var from config tests. --- pkg/config/config_test.go | 1 - pkg/config/defaults/config.api-server.local-devnet.yaml | 1 - pkg/config/defaults/config.api-server.mainnet.yaml | 1 - pkg/token/config.go | 9 ++++----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e66f43b6..d13d60fb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -411,7 +411,6 @@ func setDefaultConfigEnv(t *testing.T) { t.Setenv("CANTON_SPLICE_TRANSFER_PACKAGE_ID", "splice-transfer-package-id") t.Setenv("CANTON_SPLICE_HOLDING_PACKAGE_ID", "splice-holding-package-id") t.Setenv("CANTON_BRIDGE_PACKAGE_ID", "bridge-package-id") - t.Setenv("CANTON_USDCX_INSTRUMENT_ADMIN", "Bridge-Operator::1220test") t.Setenv("ETHEREUM_RPC_URL", "https://eth.example") t.Setenv("ETHEREUM_WS_URL", "wss://eth.example/ws") t.Setenv("ETHEREUM_CHAIN_ID", "1") diff --git a/pkg/config/defaults/config.api-server.local-devnet.yaml b/pkg/config/defaults/config.api-server.local-devnet.yaml index 5ba29195..83ad3fa8 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -64,7 +64,6 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" - instrument_admin: "" native_balance_wei: "1000000000000000000000" # Ethereum JSON-RPC facade (MetaMask compatibility) diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index fa939da9..36c885b2 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -57,7 +57,6 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" - instrument_admin: "${CANTON_USDCX_INSTRUMENT_ADMIN}" native_balance_wei: "1000000000000000000000" eth_rpc: diff --git a/pkg/token/config.go b/pkg/token/config.go index 28d238d4..b5b920a5 100644 --- a/pkg/token/config.go +++ b/pkg/token/config.go @@ -8,11 +8,10 @@ import ( // ERC20Token contains ERC-20 token metadata and Canton instrument mapping. type ERC20Token struct { - Name string `yaml:"name" validate:"required"` - Symbol string `yaml:"symbol" validate:"required"` - Decimals int `yaml:"decimals" validate:"gte=0,lte=18"` - InstrumentID string `yaml:"instrument_id" validate:"required"` - InstrumentAdmin string `yaml:"instrument_admin"` // defaults to IssuerParty if empty + Name string `yaml:"name" validate:"required"` + Symbol string `yaml:"symbol" validate:"required"` + Decimals int `yaml:"decimals" validate:"gte=0,lte=18"` + InstrumentID string `yaml:"instrument_id" validate:"required"` } // Config holds token metadata indexed by contract address. From bd583871a74e97c9dd7d4db4914c4d2dea5bcd23 Mon Sep 17 00:00:00 2001 From: Sebastian Lindner <33971232+salindne@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:33:19 -0600 Subject: [PATCH 7/7] fix(e2e): set splice_holding_package_id in canton shim token configs PR 228 made SpliceHoldingPackageID required on cantonsdk/token.Config. The e2e canton shim was missing it on both the DEMO and PROMPT token clients, causing every test to fail with 'splice_holding_package_id is required' at api/full stack init. Adds the constant alongside the other devnet package IDs (matches the value in config.api-server.local-devnet.yaml) and wires it into both token.Config instances. --- tests/e2e/devstack/shim/canton.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/devstack/shim/canton.go b/tests/e2e/devstack/shim/canton.go index 4167faca..6725a86f 100644 --- a/tests/e2e/devstack/shim/canton.go +++ b/tests/e2e/devstack/shim/canton.go @@ -19,6 +19,7 @@ import ( const ( cip56PackageID = "c8c6fe7c34d96b88d6471769aae85063c8045783b2a226fd24f8c573603d17c2" spliceTransferPackageID = "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281" + spliceHoldingPackageID = "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b" identityPackageID = "c4d8bc62b74dfb93c0feda15cbceb5db16aef37d0e7ee37c17887faa9cbd33b9" bridgePackageID = "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72" bridgeCorePackageID = "be290fc1304d9a221def6e04a291368600599c9265f58f942a2b80478c348fca" @@ -96,6 +97,7 @@ func NewCanton(manifest *stack.ServiceManifest) (*CantonShim, error) { UserID: cantonUserID, CIP56PackageID: cip56PackageID, SpliceTransferPackageID: spliceTransferPackageID, + SpliceHoldingPackageID: spliceHoldingPackageID, } demoTk, err := token.New(tokenCfg, l, demoID) if err != nil { @@ -127,6 +129,7 @@ func NewCanton(manifest *stack.ServiceManifest) (*CantonShim, error) { UserID: cantonUserID, CIP56PackageID: cip56PackageID, SpliceTransferPackageID: spliceTransferPackageID, + SpliceHoldingPackageID: spliceHoldingPackageID, } promptTk, err := token.New(promptTokenCfg, l, bridgeID) if err != nil {