Description
toParties filters accounts to usable ones (status missing or allocated) before selecting the primary, so a primary that is initialized or removed leaves the pool together with its primary flag and the first usable account gets promoted: useParty()/useParties() then report a party that is not the wallet's primary, with the real primary absent entirely. Meanwhile useExecute forwards PrepareExecuteParams to the wire untouched, and per the SDK contract an unset actAs resolves to the wallet's primary ("If not set, the primary wallet's party is used."). Net effect: the UI displays party X while an actAs-less execute is resolved to hidden party Y.
Reproduced with a running test at PR #71's ref (f36fcf9); the filter arrives with #66, the untouched forwarding has been in since #47. Found while investigating #17 (findings comment, finding 5).
Steps to reproduce
createMockAdapter({ id: 'repro', accounts: [{ partyId: 'p1', status: 'initialized' }, { partyId: 'p2', status: 'allocated' }] }) (index 0 is the adapter's primary).
- Connect through
CantonConnectProvider (auto-picker).
useParty() reports p2; useParties() is [p2]; p1 appears nowhere.
- Call
execute({ commands: [] }) from useExecute(): the captured prepareExecuteAndWait request is { "commands": [] }, no actAs/readAs, which the wallet contract resolves to its primary, p1.
Control: same setup with p1 allocated shows party = p1 and the identical actAs-less request is harmless. Full repro test below.
Expected vs actual behavior
Expected: a submit without explicit actAs acts as the party the kit reports as active, or the kit surfaces an explicit state when the wallet's primary is unusable.
Actual: the request leaves the kit without actAs and resolves to the filtered-out primary; the acting party is one the UI never displayed.
Environment
canton-connect: open stack (filter from #66; execute passthrough since #47); repro at f36fcf9 (PR #71)
@canton-network/dapp-sdk: 1.1.0 (core-wallet-dapp-rpc-client 1.4.0)
Additional context
Fix direction: inject actAs: [party.partyId] when the caller sets none, keeping the execute default in lockstep with the displayed party (applyAccounts re-pins on every accountsChanged, so the fix must hold across pushes). Aligned with #17's no-go: selection stays wallet-owned; this only aligns the default with the display.
Caveat on severity: the wallet-side fallback is contract text (SDK types), and no live wallet has been observed reporting a non-allocated primary; the kit-side halves are test-proven.
Repro test (runs green at f36fcf9, bug + control)
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { CantonConnectProvider } from './CantonConnectProvider'
import { useConnect } from './hooks/useConnect'
import { useExecute } from './hooks/useExecute'
import { useParties } from './hooks/useParties'
import { useParty } from './hooks/useParty'
import type { MockAdapter } from './mock/mockAdapter'
import { createMockAdapter } from './mock/mockAdapter'
import { createAutoPicker } from './testing/autoPicker'
const captureRequestParams = (adapter: MockAdapter, method: string): { params: unknown } => {
const captured: { params: unknown } = { params: undefined }
const provider = adapter.provider()
const original = provider.request.bind(provider)
type MockRequest = typeof original
provider.request = (args: Parameters<MockRequest>[0]): ReturnType<MockRequest> => {
if (args.method === method) {
captured.params = 'params' in args ? args.params : undefined
}
return original(args)
}
return captured
}
describe('repro: primary status vs execute() actAs', () => {
it('BUG: primary "initialized": useParty() shows a different party than the actAs-less execute() reaches', async () => {
const mock = createMockAdapter({
id: 'repro-bug',
// index 0 is always the adapter's primary, but not usable here, so toParties() filters it out
accounts: [
{ partyId: 'p1::primary-initialized', status: 'initialized' },
{ partyId: 'p2::secondary-allocated', status: 'allocated' },
],
})
const captured = captureRequestParams(mock, 'prepareExecuteAndWait')
const config = {
appName: 'test',
additionalAdapters: [mock],
walletPicker: createAutoPicker('repro-bug'),
}
const { result } = renderHook(
() => ({
connect: useConnect(),
party: useParty(),
parties: useParties(),
execute: useExecute(),
}),
{
wrapper: ({ children }) => (
<CantonConnectProvider config={config}>{children}</CantonConnectProvider>
),
},
)
await act(async () => {
await result.current.connect.connect()
})
expect(result.current.party.party?.partyId).toBe('p2::secondary-allocated')
expect(result.current.parties.parties.map((party) => party.partyId)).toEqual([
'p2::secondary-allocated',
])
await act(async () => {
await expect(result.current.execute.execute({ commands: [] })).rejects.toThrow(
"mock adapter does not implement 'prepareExecuteAndWait'",
)
})
// no actAs on the wire; per the CIP-0103 contract the wallet resolves that to ITS
// primary (p1), which is not the party useParty() told the UI to display (p2)
expect(captured.params).toEqual({ commands: [] })
expect(captured.params).not.toHaveProperty('actAs')
expect(captured.params).not.toHaveProperty('readAs')
})
it('CONTROL: primary "allocated": useParty() and the actAs-less execute() agree', async () => {
const mock = createMockAdapter({
id: 'repro-control',
accounts: [
{ partyId: 'p1::primary-allocated', status: 'allocated' },
{ partyId: 'p2::secondary-allocated', status: 'allocated' },
],
})
const captured = captureRequestParams(mock, 'prepareExecuteAndWait')
const config = {
appName: 'test',
additionalAdapters: [mock],
walletPicker: createAutoPicker('repro-control'),
}
const { result } = renderHook(
() => ({
connect: useConnect(),
party: useParty(),
parties: useParties(),
execute: useExecute(),
}),
{
wrapper: ({ children }) => (
<CantonConnectProvider config={config}>{children}</CantonConnectProvider>
),
},
)
await act(async () => {
await result.current.connect.connect()
})
expect(result.current.party.party?.partyId).toBe('p1::primary-allocated')
expect(result.current.parties.parties.map((party) => party.partyId)).toEqual([
'p1::primary-allocated',
'p2::secondary-allocated',
])
await act(async () => {
await expect(result.current.execute.execute({ commands: [] })).rejects.toThrow(
"mock adapter does not implement 'prepareExecuteAndWait'",
)
})
// still no actAs, but harmless: the wallet's on-omission primary IS the displayed party
expect(captured.params).toEqual({ commands: [] })
expect(captured.params).not.toHaveProperty('actAs')
})
})
Description
toPartiesfilters accounts to usable ones (statusmissing orallocated) before selecting the primary, so a primary that isinitializedorremovedleaves the pool together with itsprimaryflag and the first usable account gets promoted:useParty()/useParties()then report a party that is not the wallet's primary, with the real primary absent entirely. MeanwhileuseExecuteforwardsPrepareExecuteParamsto the wire untouched, and per the SDK contract an unsetactAsresolves to the wallet's primary ("If not set, the primary wallet's party is used."). Net effect: the UI displays party X while anactAs-less execute is resolved to hidden party Y.Reproduced with a running test at PR #71's ref (
f36fcf9); the filter arrives with #66, the untouched forwarding has been in since #47. Found while investigating #17 (findings comment, finding 5).Steps to reproduce
createMockAdapter({ id: 'repro', accounts: [{ partyId: 'p1', status: 'initialized' }, { partyId: 'p2', status: 'allocated' }] })(index 0 is the adapter's primary).CantonConnectProvider(auto-picker).useParty()reportsp2;useParties()is[p2];p1appears nowhere.execute({ commands: [] })fromuseExecute(): the capturedprepareExecuteAndWaitrequest is{ "commands": [] }, noactAs/readAs, which the wallet contract resolves to its primary,p1.Control: same setup with
p1allocatedshowsparty = p1and the identicalactAs-less request is harmless. Full repro test below.Expected vs actual behavior
Expected: a submit without explicit
actAsacts as the party the kit reports as active, or the kit surfaces an explicit state when the wallet's primary is unusable.Actual: the request leaves the kit without
actAsand resolves to the filtered-out primary; the acting party is one the UI never displayed.Environment
Additional context
Fix direction: inject
actAs: [party.partyId]when the caller sets none, keeping the execute default in lockstep with the displayed party (applyAccountsre-pins on everyaccountsChanged, so the fix must hold across pushes). Aligned with #17's no-go: selection stays wallet-owned; this only aligns the default with the display.Caveat on severity: the wallet-side fallback is contract text (SDK types), and no live wallet has been observed reporting a non-allocated primary; the kit-side halves are test-proven.
Repro test (runs green at f36fcf9, bug + control)