Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 35 additions & 23 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,7 @@ jobs:
wallet-sdk-scripts-e2e-multi-sync:
name: wallet-sdk-scripts-e2e-multi-sync (${{ matrix.network }})
runs-on: ubuntu-latest
needs: [build, e2e-affected]
if: needs.e2e-affected.outputs.affected_wallet_sdk == 'true'
needs: [version-config, hydrate-canton-caches]
strategy:
fail-fast: false
matrix:
Expand All @@ -469,12 +468,18 @@ jobs:
uses: actions/checkout@v6

- uses: ./.github/actions/setup_yarn
with:
daml_release_version: ${{ needs.version-config.outputs.daml_release_version }}

- uses: ./.github/actions/setup_canton
- uses: ./.github/actions/setup_localnet
with:
network: ${{ matrix.network }}
instance: localnet
multi-sync: 'true'
splice_version: ${{ matrix.network == 'devnet' && needs.version-config.outputs.devnet_splice_version || needs.version-config.outputs.mainnet_splice_version }}
start_services: 'false'

# TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well
- name: Start Localnet with multi-sync (${{ matrix.network }})
run: yarn start:localnet -- --network=${{ matrix.network }} --multi-sync

- uses: ./.github/actions/check_resources

Expand All @@ -484,12 +489,29 @@ jobs:
- name: Test multi-sync example script (${{ matrix.network }})
env:
MAX_IO_LISTENERS: '50'
run: yarn script:test:examples:multi-sync
# Retry up to 3 times: after P3 submits the token transfer, the resulting
# TokenTransferOffer may not yet be visible on P2's ACS (cross-participant
# event propagation is async). Each attempt allocates fresh parties so
# re-running against the same localnet instance is safe.
run: |
for attempt in 1 2 3; do
echo "--- Attempt $attempt/3 ---"
if yarn script:test:examples:multi-sync; then
exit 0
fi
echo "Attempt $attempt failed"
if [ $attempt -lt 3 ]; then
sleep 15
fi
done
echo "All 3 attempts failed"
exit 1

- uses: ./.github/actions/check_resources
# TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well
- name: Stop Localnet (${{ matrix.network }})
if: always()
continue-on-error: true
run: yarn stop:localnet -- --network=${{ matrix.network }} --multi-sync

- name: Save container logs
Expand All @@ -513,7 +535,6 @@ jobs:
name: test-wallet-sdk-e2e
runs-on: ubuntu-latest
needs: [
e2e-affected,
wallet-sdk-snippets-e2e,
wallet-sdk-scripts-e2e,
wallet-sdk-scripts-e2e-multi-sync, # TODO (#1721): remove multi-sync scripts e2e tests once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as a gate to ensure multi-sync e2e tests are not accidentally skipped without updating the main scripts e2e tests to cover multi-sync as well
Expand All @@ -523,27 +544,18 @@ jobs:
steps:
- name: Report wallet-sdk e2e execution
run: |
if [ "${{ needs.e2e-affected.outputs.affected_wallet_sdk }}" = "true" ]; then
if [ "${{ needs.wallet-sdk-snippets-e2e.result }}" != "success" ]; then
echo "wallet-sdk snippets e2e was scheduled but did not succeed"
exit 1
fi
if [ "${{ needs.wallet-sdk-scripts-e2e.result }}" != "success" ]; then
echo "wallet-sdk scripts e2e was scheduled but did not succeed"
exit 1
fi
if [ "${{ needs.wallet-sdk-scripts-e2e-multi-sync.result }}" != "success" ]; then
echo "wallet-sdk scripts e2e (multi-sync) was scheduled but did not succeed"
exit 1
fi
echo "all wallet-sdk-e2e jobs passed"
else
echo "wallet-sdk e2e skipped (no affected wallet-sdk dependencies)"
if [ "${{ needs.wallet-sdk-snippets-e2e.result }}" != "success" ]; then
echo "wallet-sdk snippets e2e did not succeed"
exit 1
fi
if [ "${{ needs.wallet-sdk-scripts-e2e.result }}" != "success" ]; then
echo "wallet-sdk scripts e2e did not succeed"
exit 1
fi
if [ "${{ needs.wallet-sdk-scripts-e2e-multi-sync.result }}" != "success" ]; then
echo "wallet-sdk scripts e2e (multi-sync) did not succeed"
exit 1
fi
if [ "${{ needs.wallet-sdk-pkg.result }}" != "success" ]; then
echo "wallet-sdk package validation did not succeed"
exit 1
Expand Down
37 changes: 30 additions & 7 deletions canton/multi-sync/app-synchronizer.sc
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,29 @@ bootstrap.synchronizer(
staticSynchronizerParameters = StaticSynchronizerParameters.defaultsWithoutKMS(ProtocolVersion.latest),
)

// Connect app-user and app-provider to the new synchronizer.
// Connect all participants to the new synchronizer.
// app-user — global + app-synchronizer
// app-provider — global + app-synchronizer
// sv — global only (TradingApp is only an observer of Token Allocations;
// it learns about them when they are reassigned to global before settlement)
// sv — global + app-synchronizer (TokenAdmin on sv submits TokenRules
// and Token contracts on the app-synchronizer)
//
// The global domain is connected first (before this bootstrap script runs),
// so connectedSynchronizers[0] remains global for all participants — the
// default synchronizer selection is unaffected.
`app-provider`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer")
`app-user`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer")
`sv`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer")

// Wait for both participants to be active on app-synchronizer
// Wait for all participants to be active on app-synchronizer
utils.retry_until_true {
`app-provider`.synchronizers.active("app-synchronizer")
}
utils.retry_until_true {
`app-user`.synchronizers.active("app-synchronizer")
}
utils.retry_until_true {
`sv`.synchronizers.active("app-synchronizer")
}

// Vet packages on app-synchronizer for all three participants.
// The Splice app already uploaded DARs and vetted them on global-domain.
Expand All @@ -39,7 +43,7 @@ val appSyncId = `app-provider`.synchronizers.list_connected()
.getOrElse(throw new RuntimeException("app-synchronizer not found in connected synchronizers"))
.synchronizerId

for (participant <- Seq(`app-provider`, `app-user`)) {
for (participant <- Seq(`app-provider`, `app-user`, `sv`)) {
val vettedFromAuthorized = participant.topology.vetted_packages
.list(store = Some(TopologyStoreId.Authorized), filterParticipant = participant.id.filterString)
.flatMap(_.item.packages)
Expand All @@ -54,7 +58,7 @@ for (participant <- Seq(`app-provider`, `app-user`)) {
}
}

// Wait for vetting topology to propagate for app-provider and app-user
// Wait for vetting topology to propagate for all participants
utils.retry_until_true {
val providerVetted = `app-provider`.topology.vetted_packages
.list(store = Some(appSyncId), filterParticipant = `app-provider`.id.filterString)
Expand All @@ -65,5 +69,24 @@ utils.retry_until_true {
.list(store = Some(appSyncId), filterParticipant = `app-user`.id.filterString)
userVetted.nonEmpty && userVetted.head.item.packages.nonEmpty
}
utils.retry_until_true {
val svVetted = `sv`.topology.vetted_packages
.list(store = Some(appSyncId), filterParticipant = `sv`.id.filterString)
svVetted.nonEmpty && svVetted.head.item.packages.nonEmpty
}

logger.info("app-synchronizer bootstrap with package vetting completed successfully for app-provider and app-user")
logger.info("app-synchronizer bootstrap with package vetting completed successfully for app-provider, app-user, and sv")

// Final gate: confirm all participants are active on the global synchronizer
// (Canton alias "global", as configured in conf/splice/app.conf domains.global.alias).
// On slower CI environments (e.g. devnet) sv's global synchronizer ledger API connection
// can still be initialising when the app-synchronizer steps above finish.
// docker wait multi-sync-startup will not return until this check passes,
// preventing the "Unknown or not connected synchronizer global-domain::..." error
// that occurs when party allocation is attempted before sv is ready.
utils.retry_until_true {
`app-provider`.synchronizers.active("global") &&
`app-user`.synchronizers.active("global") &&
`sv`.synchronizers.active("global")
}
logger.info("All participants confirmed active on global synchronizer — localnet ready")
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export const LOCALNET_BOB_LEDGER_URL = new URL('http://localhost:3975')
// trading-app-participant JSON API (4 + PARTICIPANT_JSON_API_PORT_SUFFIX 975)
export const LOCALNET_TRADING_APP_LEDGER_URL = new URL('http://localhost:4975')

// TestToken Token Standard registry (hosted in-process by example 15)
export const LOCALNET_TEST_TOKEN_REGISTRY_PORT = parseInt(
process.env['REGISTRY_PORT'] ?? '5975',
10
)
export const LOCALNET_TEST_TOKEN_REGISTRY_URL = new URL(
`http://localhost:${LOCALNET_TEST_TOKEN_REGISTRY_PORT}`
)

// Party hint labels used when allocating parties
export const PARTY_HINT_ALICE = 'Alice'
export const PARTY_HINT_BOB = 'Bob'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* TestToken admin endpoint handlers.
*
* These endpoints allow the example to bootstrap on-ledger state
* (TokenRules + Token holdings) entirely through the registry's off-ledger
* HTTP API, without the example code ever calling the Daml Ledger API directly
* for Token-related operations.
*
* POST /admin/v1/setup — creates TokenRules on both synchronizers
* POST /admin/v1/mint — mints a Token holding for tokenAdmin
*/

import type { AdminHandlers, SubmitAsTokenAdmin } from '../../types.js'
import { invalidateCache } from '../../ledger.js'

const TEST_TOKEN_PREFIX =
'#splice-test-token-v1:Splice.Testing.Tokens.TestTokenV1'

export interface AdminHandlerContext {
tokenAdminPartyId: string
globalSynchronizerId: string
appSynchronizerId: string
submitAsTokenAdmin: SubmitAsTokenAdmin
}

export function createAdminHandlers(ctx: AdminHandlerContext): AdminHandlers {
return {
async setupTokenRules(): Promise<void> {
// Create TokenRules on both synchronizers in parallel so the
// registry's ACS cache picks them up on the next call.
await Promise.all([
ctx.submitAsTokenAdmin({
commands: {
CreateCommand: {
templateId: `${TEST_TOKEN_PREFIX}:TokenRules`,
createArguments: { admin: ctx.tokenAdminPartyId },
},
},
synchronizerId: ctx.globalSynchronizerId,
}),
ctx.submitAsTokenAdmin({
commands: {
CreateCommand: {
templateId: `${TEST_TOKEN_PREFIX}:TokenRules`,
createArguments: { admin: ctx.tokenAdminPartyId },
},
},
synchronizerId: ctx.appSynchronizerId,
}),
])
// Invalidate the ACS cache so transfer-factory requests see the
// newly created TokenRules contracts immediately.
invalidateCache()
},

async mintToken({ amount }: { amount: string }): Promise<void> {
await ctx.submitAsTokenAdmin({
commands: [
{
CreateCommand: {
templateId: `${TEST_TOKEN_PREFIX}:Token`,
createArguments: {
holding: {
owner: ctx.tokenAdminPartyId,
instrumentId: {
admin: ctx.tokenAdminPartyId,
id: 'TestToken',
},
amount,
lock: null,
meta: { values: {} },
},
},
},
},
],
synchronizerId: ctx.appSynchronizerId,
})
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* TestToken implementation of AllocationInstructionHandlers.
*
* Resolves the AllocationFactory by looking up the live TokenRules contract on the
* *global* synchronizer from the ledger ACS. For trade settlement the token must
* be allocated on the global (trade) synchronizer, so we always return the
* TokenRules contract that lives there. The TokenRules contract is also included
* as a disclosed contract so the wallet SDK can pass it through to the Ledger API
* when exercising AllocationFactory_Allocate via the interface.
*/

import type {
FactoryWithChoiceContext,
AllocationInstructionHandlers,
GetFactoryRequest,
} from '../../types.js'
import type { TokenRulesContract } from '../../ledger.js'

export interface AllocationInstructionHandlerContext {
/** Returns the TokenRules on the requested synchronizer, or the first one if not found. */
getTokenRules: (
synchronizerId?: string
) => Promise<TokenRulesContract | null>
/** ID of the global (trade) synchronizer — allocations must use this synchronizer's factory. */
globalSynchronizerId: string
}

export function createAllocationInstructionHandlers(
ctx: AllocationInstructionHandlerContext
): AllocationInstructionHandlers {
return {
getAllocationFactory: async (
_req: GetFactoryRequest
): Promise<FactoryWithChoiceContext | null> => {
// Always use the global-synchronizer TokenRules as the allocation factory.
// Allocations for trade settlement are executed on the global synchronizer,
// so the factory contract must live there.
const tokenRules = await ctx.getTokenRules(ctx.globalSynchronizerId)
if (!tokenRules) return null
return {
factoryId: tokenRules.contractId,
choiceContext: {
choiceContextData: {},
disclosedContracts: [
{
templateId: tokenRules.templateId,
contractId: tokenRules.contractId,
createdEventBlob: tokenRules.createdEventBlob,
synchronizerId: tokenRules.synchronizerId,
},
],
},
}
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* TestToken implementation of AllocationHandlers.
*
* All allocation choice-context endpoints return an empty context — no extra
* contracts need to be disclosed for execute-transfer, withdraw, or cancel.
*/

import type { ChoiceContext, AllocationHandlers } from '../../types.js'

export function createAllocationHandlers(): AllocationHandlers {
const emptyContext: ChoiceContext = {
choiceContextData: {},
disclosedContracts: [],
}

return {
getAllocationTransferContext: async (): Promise<ChoiceContext> =>
emptyContext,
getAllocationWithdrawContext: async (): Promise<ChoiceContext> =>
emptyContext,
getAllocationCancelContext: async (): Promise<ChoiceContext> =>
emptyContext,
}
}
Loading
Loading