Skip to content

Execute defaults to the wallet's primary even when the kit filtered it out of the party list #73

Description

@fernandomg

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

  1. createMockAdapter({ id: 'repro', accounts: [{ partyId: 'p1', status: 'initialized' }, { partyId: 'p2', status: 'allocated' }] }) (index 0 is the adapter's primary).
  2. Connect through CantonConnectProvider (auto-picker).
  3. useParty() reports p2; useParties() is [p2]; p1 appears nowhere.
  4. 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')
  })
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: connectcanton-connect: hooks, adapters, session, SDK facadebugSomething isn't workingpriority: mediumShould be addressed soon

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions