3.4 Cross-Domain Atomic Coordination (Flow C)
When a rebalance requires coordinated actions across two or more independent public chains, Canton must coordinate an atomic sequence of intents.
Challenge: Multi-Chain Atomicity
Within the Canton network, atomicity is guaranteed for the issuance of the intents themselves, thanks to Daml's transaction model which allows truly global workflow composition across multiple synchronization domains.
However, since public chains (e.g., Chain A and Chain B) do not natively participate in Canton's two-phase commit protocol, the Relayer must implement external, application-level atomicity.
Architecture
┌──────────────────────────────┐
│ Canton Domain │
│ │
│ ┌────────────────────────┐ │
│ │ Multi-Chain Workflow │ │
│ │ │ │
│ │ create Intent_A │ │
│ │ create Intent_B │ │
│ │ (atomically) │ │
│ └───┬────────────────┬───┘ │
└──────┼────────────────┼─────┘
│ Intent_A │ Intent_B
│ │
┌──────▼────────────────▼─────┐
│ Relayer Service │
│ • Link intents by groupId │
│ • Submit concurrently │
│ • Monitor both chains │
│ • Handle partial failures │
└──────┬────────────────┬─────┘
│ Tx_A │ Tx_B
│ │
┌──────▼─────┐ ┌──────▼─────┐
│ Chain A │ │ Chain B │
│ (Ethereum) │ │ (Polygon) │
└────────────┘ └────────────┘Canton Side: Atomic Intent Issuance
Daml Workflow
-- Multi-chain rebalance workflow
template MultiChainRebalance
with
proposer: Party
vaultIdA: Text
vaultIdB: Text
actionsA: [Action]
actionsB: [Action]
groupId: Text -- Links the intents
where
signatory proposer
choice ExecuteMultiChain : (ContractId ExecutionIntent, ContractId ExecutionIntent)
controller proposer
do
now <- getTime
-- Create both intents atomically
intentA <- create ExecutionIntent with
vaultId = vaultIdA
nonce = nonceA
actions = actionsA
deadline = addRelTime now (hours 1)
groupId = Some groupId
intentB <- create ExecutionIntent with
vaultId = vaultIdB
nonce = nonceB
actions = actionsB
deadline = addRelTime now (hours 1)
groupId = Some groupId
-- Both created or neither created (atomicity)
return (intentA, intentB)Key Features
Atomicity Guarantee:
- Both intents created in single Daml transaction
- If one fails, both fail
- Canton ledger ensures consistency
Group Linking:
- Shared
groupIdlinks related intents - Relayer can identify coordinated operations
- Enables cross-chain compensation logic
Relayer Side: Coordinated Execution
Intent Grouping
interface IntentGroup {
groupId: string;
intents: ExecutionIntent[];
status: 'pending' | 'executing' | 'completed' | 'failed';
results: Map<string, TransactionReceipt>;
}
async function processIntentGroup(
group: IntentGroup
): Promise<void> {
try {
// 1. Prepare all transactions
const txs = await Promise.all(
group.intents.map(intent => prepareTransaction(intent))
);
// 2. Submit concurrently
const submissions = txs.map((tx, i) =>
submitTransaction(tx, group.intents[i].targetChain)
);
// 3. Wait for all confirmations
const results = await Promise.all(submissions);
// 4. Verify all succeeded
const allSucceeded = results.every(r => r.status === 1);
if (!allSucceeded) {
// Partial failure - initiate compensation
await handlePartialFailure(group, results);
} else {
// All succeeded
await reportGroupSuccess(group, results);
}
} catch (error) {
await handleGroupFailure(group, error);
}
}Concurrent Submission
async function submitConcurrently(
intents: ExecutionIntent[]
): Promise<TransactionReceipt[]> {
// Submit to all chains simultaneously
const promises = intents.map(intent =>
submitToChain(intent, intent.targetChain)
);
// Race against deadline
const deadline = Math.min(...intents.map(i => i.deadline));
const timeoutMs = deadline - Date.now();
return await Promise.race([
Promise.all(promises),
timeout(timeoutMs)
]);
}Failure Handling
Compensation Logic
When Tx_A succeeds but Tx_B fails, the system must compensate:
async function handlePartialFailure(
group: IntentGroup,
results: TransactionReceipt[]
): Promise<void> {
// Identify which transactions succeeded
const succeeded = results
.map((r, i) => ({ intent: group.intents[i], result: r }))
.filter(({ result }) => result.status === 1);
// Create compensation intents in Canton
for (const { intent, result } of succeeded) {
await requestCompensation(intent, result);
}
}Canton Compensation Contract
template CompensationRequest
with
originalIntent: ContractId ExecutionIntent
groupId: Text
failedChain: Text
proposer: Party
where
signatory proposer
choice IssueCompensation : ContractId ExecutionIntent
controller proposer
do
-- Fetch original intent
original <- fetch originalIntent
-- Generate reversal actions
let reversalActions = reverseActions original.actions
-- Create compensation intent
create ExecutionIntent with
vaultId = original.vaultId
nonce = original.nonce + 1000 -- Special nonce range
actions = reversalActions
deadline = addRelTime (getTime) (minutes 30)
isCompensation = True
originalGroupId = Some groupIdReversal Actions
function reverseActions(
actions: Action[],
originalTx: TransactionReceipt
): Action[] {
return actions.map(action => {
switch (action.operation) {
case 'swap':
// Reverse the swap
return {
protocol: action.protocol,
operation: 'swap',
params: {
tokenIn: action.params.tokenOut, // Reversed
tokenOut: action.params.tokenIn, // Reversed
amountIn: getActualOutput(originalTx),
minAmountOut: 0 // Best effort
}
};
case 'supply':
// Withdraw what was supplied
return {
protocol: action.protocol,
operation: 'withdraw',
params: {
asset: action.params.asset,
amount: action.params.amount
}
};
case 'stake':
// Unstake
return {
protocol: action.protocol,
operation: 'unstake',
params: {
asset: action.params.asset,
amount: action.params.amount
}
};
default:
throw new Error(`Cannot reverse: ${action.operation}`);
}
});
}State Reconciliation
Canton State Update
template MultiChainState
with
groupId: Text
controller: Party
chainStates: [(Text, ExecutionStatus)]
where
signatory controller
choice UpdateChainStatus : ContractId MultiChainState
with
chain: Text
status: ExecutionStatus
controller controller
do
let updatedStates = updateStatus chainStates chain status
create this with chainStates = updatedStates
choice FinalizeGroup : ()
controller controller
do
-- Verify all chains completed
assertMsg "Not all chains completed"
(all ((== Completed) . snd) chainStates)
-- Archive this state contract
return ()Relayer Reporting
async function reportExecutionStatus(
groupId: string,
chain: string,
receipt: TransactionReceipt
): Promise<void> {
const status: ExecutionStatus = {
chain,
txHash: receipt.transactionHash,
success: receipt.status === 1,
timestamp: Date.now(),
gasUsed: receipt.gasUsed.toString()
};
// Update Canton state
await canton.exercise({
templateId: 'Vault.Policy:MultiChainState',
contractId: await findStateContract(groupId),
choice: 'UpdateChainStatus',
argument: { chain, status }
});
}Timeout Handling
Deadline Enforcement
async function executeWithTimeout(
group: IntentGroup
): Promise<void> {
const deadline = Math.min(...group.intents.map(i => i.deadline));
const timeoutMs = deadline - Date.now();
try {
await Promise.race([
processIntentGroup(group),
sleep(timeoutMs).then(() => {
throw new Error('Group execution timeout');
})
]);
} catch (error) {
if (error.message === 'Group execution timeout') {
// Cancel pending transactions
await cancelPendingTransactions(group);
// Report timeout to Canton
await reportGroupTimeout(group);
}
throw error;
}
}Best Practices
1. Design for Idempotency
All operations should be idempotent:
function executeIntent(...) external {
bytes32 intentHash = keccak256(intentPayload);
// Check if already executed
if (executedIntents[intentHash]) {
emit IntentAlreadyExecuted(intentHash);
return;
}
executedIntents[intentHash] = true;
_executeActions(...);
}2. Minimize Cross-Chain Dependencies
// Bad: Circular dependency
Chain A: Withdraw X → Send to Chain B
Chain B: Wait for X → Stake X → Send Y to Chain A
// Good: Independent operations
Chain A: Rebalance internally (X → Y)
Chain B: Rebalance internally (M → N)3. Use Explicit Checkpoints
template MultiChainCheckpoint
with
groupId: Text
phase: Text -- "prepared" | "executed" | "confirmed"
completedChains: [Text]
where
signatory controller
choice AdvancePhase : ContractId MultiChainCheckpoint
with chain: Text
controller controller
do
create this with
completedChains = chain :: completedChains4. Monitor Cross-Chain Latency
const metrics = {
crossChainLatency: new Histogram({
name: 'cross_chain_latency_seconds',
help: 'Time to complete multi-chain operation',
labelNames: ['chains', 'success']
}),
compensationRate: new Counter({
name: 'compensations_total',
help: 'Number of compensation transactions'
})
};
async function trackMultiChainExecution(
group: IntentGroup
): Promise<void> {
const startTime = Date.now();
try {
await processIntentGroup(group);
metrics.crossChainLatency.observe(
{ chains: group.intents.length, success: 'true' },
(Date.now() - startTime) / 1000
);
} catch (error) {
metrics.crossChainLatency.observe(
{ chains: group.intents.length, success: 'false' },
(Date.now() - startTime) / 1000
);
metrics.compensationRate.inc();
throw error;
}
}Testing Scenarios
Test Case 1: Happy Path
test('multi-chain execution succeeds on all chains', async () => {
const group = createTestGroup(['ethereum', 'polygon']);
const result = await executeMultiChain(group);
expect(result.status).toBe('completed');
expect(result.results.size).toBe(2);
expect(Array.from(result.results.values())).toEqual([
expect.objectContaining({ status: 1 }),
expect.objectContaining({ status: 1 })
]);
});Test Case 2: Partial Failure
test('compensates when one chain fails', async () => {
const group = createTestGroup(['ethereum', 'polygon']);
// Mock polygon failure
mockChain('polygon').revert();
await executeMultiChain(group);
// Verify compensation was issued
const compensations = await canton.query('CompensationRequest');
expect(compensations).toHaveLength(1);
expect(compensations[0].failedChain).toBe('polygon');
});Test Case 3: Timeout
test('handles group timeout', async () => {
const group = createTestGroup(['ethereum', 'slowchain']);
// Set tight deadline
group.intents[1].deadline = Date.now() + 1000;
// Mock slow execution
mockChain('slowchain').delay(5000);
await expect(executeMultiChain(group)).rejects.toThrow('timeout');
// Verify Canton was notified
const states = await canton.query('MultiChainState');
expect(states[0].status).toBe('timeout');
});Limitations and Considerations
Eventual Consistency
Multi-chain atomicity is application-level, not protocol-level. There is always a window where chains are in inconsistent states. Design compensation logic carefully.
Limitations:
- Public chains have different block times and finality
- Network partitions can cause extended inconsistency
- Gas price spikes may delay one chain's execution
- Reorgs can invalidate "confirmed" transactions
Mitigations:
- Wait for deep finality before considering tx final
- Set generous deadlines accounting for worst-case latency
- Implement robust compensation logic
- Monitor and alert on cross-chain execution delays
