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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, vi, it, expect, beforeEach } from 'vitest'
import { expressContext, mock, RequestType } from '../../__test__/mocks'
import { APIError, emptyChoiceContext } from '../common'
import { getAllocationFactory } from './getAllocationFactory'
import { synchronizerId } from '../../common/synchronizer'

const { res, next } = expressContext

Expand Down Expand Up @@ -48,6 +49,8 @@ vi.mock('@canton-network/core-splice-codegen', () => ({
describe('Allocation Instruction', () => {
beforeEach(() => {
vi.clearAllMocks()
synchronizerId.transferInstruction = ''
synchronizerId.allocationInstruction = ''
})

it('should successfully return factory contract from acs reader', async () => {
Expand Down Expand Up @@ -105,4 +108,48 @@ describe('Allocation Instruction', () => {
choiceContext: emptyChoiceContext,
})
})

it('should return factory matching allocation synchronizer id', async () => {
const request = {} as RequestType<typeof getAllocationFactory>

synchronizerId.allocationInstruction = 'allocation-sync-id'
mock.sdk.ledger.acsReader.readJsContracts.mockResolvedValueOnce([
{
contractId: 'cid-1',
synchronizerId: 'some-other-sync-id',
},
{
contractId: 'cid-2',
synchronizerId: 'allocation-sync-id',
},
])

await getAllocationFactory(request, res, next)

expect(res.json).toHaveBeenCalledWith({
factoryId: 'cid-2',
choiceContext: emptyChoiceContext,
})
})

it('should pass allocation synchronizer id when creating factory contract', async () => {
const request = {} as RequestType<typeof getAllocationFactory>

synchronizerId.allocationInstruction = 'allocation-sync-id'
mock.sdk.ledger.acsReader.readJsContracts
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
contractId: 'cid',
},
])

await getAllocationFactory(request, res, next)

expect(mock.prepare).toHaveBeenCalledWith(
expect.objectContaining({
synchronizerId: 'allocation-sync-id',
})
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TestToken } from '@canton-network/core-splice-codegen'
import { APIError, emptyChoiceContext } from '../common'
import { OffLedger } from '@canton-network/core-token-standard'
import { TExpressOpenApiRequestHandler } from 'openapi-ts-router/express'
import { synchronizerId } from '../../common/synchronizer'

/**
* Resolves or creates an allocation factory for initiating allocation workflows.
Expand All @@ -18,17 +19,30 @@ export const getAllocationFactory: TExpressOpenApiRequestHandler<
OffLedger.AllocationInstructionV1.paths['/registry/allocation-instruction/v1/allocation-factory']['post']
> = async (_req, res, next) => {
// fetch factory contract (if existing)...
const fetchedFactory = (
await sdk.ledger.acsReader.readJsContracts({
filterByParty: true,
parties: [operator.party],
templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId],
})
)[0]
const fetchedFactories = await sdk.ledger.acsReader.readJsContracts({
filterByParty: true,
parties: [operator.party],
templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId],
})

// multi-sync mode
if (synchronizerId.allocationInstruction) {
const syncFactory = fetchedFactories.find(
(factory) =>
factory.synchronizerId === synchronizerId.allocationInstruction
)
if (syncFactory) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If syncFactory is undefined, because none of factories matched synchronizerId.allocationInstruction, but fetchedFactories has at least one item, then we return fetchedFactories[0].

I think a better flow in "multi-sync mode" would be to skip returning fetchedFactories[0] if no factory matches sync id, and go straight to "...and create one otherwise" part.

res.json({
factoryId: syncFactory.contractId,
choiceContext: emptyChoiceContext,
})
return
}
}

if (fetchedFactory) {
if (fetchedFactories[0]) {
res.json({
factoryId: fetchedFactory.contractId,
factoryId: fetchedFactories[0].contractId,
choiceContext: emptyChoiceContext,
})
return
Expand All @@ -41,6 +55,9 @@ export const getAllocationFactory: TExpressOpenApiRequestHandler<
commands: TestToken.commands.create.rules({
admin: operator.party,
}),
...(synchronizerId.allocationInstruction
? { synchronizerId: synchronizerId.allocationInstruction }
: {}),
})
.sign(operator.keys.privateKey)
.execute({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import z from 'zod'
import { APIError, emptyChoiceContext } from '../common'
import { OffLedger } from '@canton-network/core-token-standard'
import { TExpressOpenApiRequestHandler } from 'openapi-ts-router/express'
import { synchronizerId } from '../../common/synchronizer'

export const getTransferFactoryChoiceArgumentsSchema = z.object({
sender: z.string(),
receiver: z.string(),
transferKind: z.optional(
z.union([z.literal('self'), z.literal('offer'), z.literal('direct')])
),
transferKind: z
.union([z.literal('self'), z.literal('offer'), z.literal('direct')])
.optional(),
})

/**
Expand Down Expand Up @@ -51,17 +52,31 @@ export const getTransferFactory: TExpressOpenApiRequestHandler<
parsedChoiceArguments.data.transferKind ?? (isToSelf ? 'self' : 'offer')

// fetch the factory contract (if existing)...
const fetchedFactory = (
await sdk.ledger.acsReader.readJsContracts({
filterByParty: true,
parties: [operator.party],
templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId],
})
)[0]
const fetchedFactories = await sdk.ledger.acsReader.readJsContracts({
filterByParty: true,
parties: [operator.party],
templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId],
})

// multi-sync mode
if (synchronizerId.transferInstruction) {
const syncFactory = fetchedFactories.find(
(factory) =>
factory.synchronizerId === synchronizerId.transferInstruction
)
if (syncFactory) {
res.json({
factoryId: syncFactory.contractId,
transferKind,
choiceContext: emptyChoiceContext,
})
return
}
}

if (fetchedFactory) {
if (fetchedFactories[0]) {
res.json({
factoryId: fetchedFactory.contractId,
factoryId: fetchedFactories[0].contractId,
transferKind,
choiceContext: emptyChoiceContext,
})
Expand All @@ -75,6 +90,9 @@ export const getTransferFactory: TExpressOpenApiRequestHandler<
commands: TestToken.commands.create.rules({
admin: operator.party,
}),
...(synchronizerId.transferInstruction
? { synchronizerId: synchronizerId.transferInstruction }
: {}),
})
.sign(operator.keys.privateKey)
.execute({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getTransferInstructionWithdrawContext } from './getTransferInstructionW
import { getTransferFactory } from './getTransferFactory'
import { APIError, emptyChoiceContext } from '../common'
import { expressContext, mock, RequestType } from '../../__test__/mocks'
import { synchronizerId } from '../../common/synchronizer'

const { res, next } = expressContext

Expand Down Expand Up @@ -51,6 +52,8 @@ vi.mock('@canton-network/core-splice-codegen', () => ({
describe('Transfer Instruction', () => {
beforeEach(() => {
vi.clearAllMocks()
synchronizerId.transferInstruction = ''
synchronizerId.allocationInstruction = ''
})

it('should get accept choice context', () => {
Expand Down Expand Up @@ -250,5 +253,56 @@ describe('Transfer Instruction', () => {
choiceContext: emptyChoiceContext,
})
})

it('should return factory matching transfer synchronizer id', async () => {
const request = getTransferFactoryRequest({
sender: 's',
receiver: 'r',
})

synchronizerId.transferInstruction = 'transfer-sync-id'
mock.sdk.ledger.acsReader.readJsContracts.mockResolvedValueOnce([
{
contractId: 'cid-1',
synchronizerId: 'some-other-sync-id',
},
{
contractId: 'cid-2',
synchronizerId: 'transfer-sync-id',
},
])

await getTransferFactory(request, res, next)

expect(res.json).toHaveBeenCalledWith({
factoryId: 'cid-2',
transferKind: 'offer',
choiceContext: emptyChoiceContext,
})
})

it('should pass transfer synchronizer id when creating factory contract', async () => {
const request = getTransferFactoryRequest({
sender: 's',
receiver: 'r',
})

synchronizerId.transferInstruction = 'transfer-sync-id'
mock.sdk.ledger.acsReader.readJsContracts
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
contractId: 'cid',
},
])

await getTransferFactory(request, res, next)

expect(mock.prepare).toHaveBeenCalledWith(
expect.objectContaining({
synchronizerId: 'transfer-sync-id',
})
)
})
})
})
6 changes: 5 additions & 1 deletion examples/test-token-v1-registry/src/common/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export const operator = {
keys: sdk.keys.generate(),
}

export const initOperatorParty = async () => {
export const initOperatorParty = async (admin?: typeof operator) => {
if (admin) {
Object.assign(operator, admin)
return
}
const createdParty = await sdk.party.external
.create(operator.keys.publicKey, {
partyHint: 'operator',
Expand Down
30 changes: 30 additions & 0 deletions examples/test-token-v1-registry/src/common/synchronizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, afterEach } from 'vitest'
import { assignSynchronizerIds, synchronizerId } from './synchronizer'

describe('synchronizer', () => {
afterEach(() => {
Object.assign(synchronizerId, {
transferInstruction: '',
allocationInstruction: '',
})
})
it('should be set to empty strings by default', () => {
expect(synchronizerId).toStrictEqual({
transferInstruction: '',
allocationInstruction: '',
})
})
it('should properly assign syncrhonizers', () => {
const expectedResult = {
transferInstruction: 'transfer-sync-id',
allocationInstruction: 'allocation-sync-id',
}

assignSynchronizerIds(expectedResult)

expect(synchronizerId).toStrictEqual(expectedResult)
})
})
11 changes: 11 additions & 0 deletions examples/test-token-v1-registry/src/common/synchronizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

export const synchronizerId = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm suspicious of that approach to have a shared module level object with sync ids that is mutated after calling startRegistry if options?.synchronizerIds is provided, while stopRegistry doesn't reset it to empty state.

Example problematic scenario:
-startRegistry with options?.synchronizerIds -> synchronizerId modified, getAllocationFactory would check this syncs id
-stopRegistry
-startRegistry without options?.synchronizerIds -> synchronizerId from previous startRegistry still present, getAllocationFactory would check sync ids from previous startRegistry.

I would at least clear it in stopRegistry.
Or you could consider a different approach where syncIds live only inside registry instance, instead of a common module level object. Seems safer and easier to modify, i.e. if you wanted an option to have multiple instances of registry at the same time for some reason. But this is far-fetched scenario which I don't know we will ever need, so for me it's only important that you assure uninteded synchronizer ids don't appear when we didn't want them, so cleanup in stopRegistry probably would suffice.

transferInstruction: '',
allocationInstruction: '',
}

export const assignSynchronizerIds = (sync: typeof synchronizerId) => {
Object.assign(synchronizerId, sync)
}
Loading