Skip to content

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

daml
-- 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 groupId links related intents
  • Relayer can identify coordinated operations
  • Enables cross-chain compensation logic

Relayer Side: Coordinated Execution

Intent Grouping

typescript
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

typescript
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:

typescript
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

daml
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 groupId

Reversal Actions

typescript
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

daml
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

typescript
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

typescript
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:

solidity
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

daml
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 :: completedChains

4. Monitor Cross-Chain Latency

typescript
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

typescript
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

typescript
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

typescript
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

Canton DeFi - Multichain DeFi Technical Reference