Catalog npm
Back to packageRELEASE DETAILS

0.4.1

npm

@0xsend/external-signing

TypeScript library for external signing on the Canton Network

Published12 Oct 2026
Direct dependencies16
Transitive dependenciesNot calculated

README

Current package documentation

Canton External Signing

A TypeScript library for integrating external signing mechanisms with the Canton Network, enabling secure transaction signing using external key management systems, hardware security modules (HSMs), or software wallets.

Overview

Canton External Signing provides a flexible framework for signing Canton Network transactions without exposing private keys to the application. This is essential for production deployments where key security is paramount.

Key Features

  • 🔐 Secure Key Management - Keep private keys in HSMs, secure enclaves, or external wallets
  • 🔑 Multiple Signer Support - Register and manage multiple signing identities
  • ⚡ Async Signing - Non-blocking signature operations with timeout support
  • 🛡️ Type Safety - Full TypeScript support with strict typing
  • 🧪 Well Tested - Comprehensive test suite included
  • 🔌 Extensible - Easy to implement custom signers
  • 📝 Prepare/Submit Pattern - Canton handles transaction building, client only signs

Architecture

Overall System Architecture

graph TB
    subgraph "Browser/Client"
        WA[Web App]
        ES[External Signer]
        KS[Key Storage]
    end

    subgraph "Canton Network"
        LED[Ledger API]
        VAL[Validator]
        SYNC[Synchronizer]
    end

    WA -->|1. Prepare Tx| LED
    LED -->|2. Return Hash| WA
    WA -->|3. Sign Hash| ES
    ES -->|4. Access Key| KS
    KS -->|5. Return Signature| ES
    ES -->|6. Return Signature| WA
    WA -->|7. Submit Signed Tx| LED
    LED -->|8. Validate| VAL
    VAL -->|9. Commit| SYNC

Signing Flow Sequence

sequenceDiagram
    participant User
    participant WebApp
    participant Signer
    participant Canton

    User->>WebApp: Initiate Transaction
    WebApp->>Canton: Prepare Command
    Canton->>WebApp: Return Tx Hash
    WebApp->>Signer: Request Signature
    Signer->>Signer: Generate Signature
    Signer->>WebApp: Return Signature
    WebApp->>Canton: Submit Signed Tx
    Canton->>WebApp: Confirmation
    WebApp->>User: Show Result

Component Architecture

graph LR
    subgraph "External Signing Package"
        API[API Client]
        SM[Signer Manager]
        CS[Crypto Services]
        ST[Storage]
    end

    subgraph "Signer Implementations"
        MEM[Memory Signer]
        HSM[HSM Signer]
        HW[Hardware Wallet]
        ED[Ed25519 Signer]
    end

    API --> SM
    SM --> CS
    SM --> ST
    CS --> MEM
    CS --> HSM
    CS --> HW
    CS --> ED

Installation

# Using yarn (recommended for canton-monorepo)
yarn add @0xsend/external-signing

# Using npm
npm install @0xsend/external-signing

Quick Start

Basic Example

import {
  CantonExternalClient,
  InMemorySigner,
  createTestSigner,
} from "@0xsend/external-signing";

// 1. Create a client instance
const client = new CantonExternalClient({
  ledgerApiUrl: "https://canton.example.com/",
  token: "your-jwt-token",
});

// 2. Create and register a signer
const signer = await createTestSigner("my-signer", "ES256");
client.registerSigner(signer);

// 3. Create a DAML command
const command = {
  template: MyTemplate,
  argument: {
    owner: "alice",
    value: 100,
  },
  party: "alice::1234...",
};

// 4. Submit with external signing
const result = await client.submitCreateCommand(command, "my-signer", {
  verifySignature: true,
});

console.log("Transaction ID:", result.transactionId);

Using the Prepare/Submit Pattern

For more control over the signing process, use the prepare/submit pattern:

import { CantonExternalClient, Ed25519Signer } from "@0xsend/external-signing";

// Create client with external party API
const client = new CantonExternalClient({
  validatorUrl: "https://validator.example.com/",
  authToken: "your-auth-token",
});

// Create an Ed25519 signer
const signer = new Ed25519Signer();

// Create external party
const party = await client.createExternalParty("alice", signer);
console.log("Party ID:", party.partyId);

// Execute a transfer with prepare/submit
const transferParams = {
  sender_party_id: party.partyId,
  receiver_party_id: "bob::5678...",
  amount: "100.00",
  transfer_preapproval_contract_id: "contract-id",
};

const result = await client.executeTransferPreapproval(transferParams, signer);

API Reference

CantonExternalClient

The main client for interacting with Canton using external signers.

class CantonExternalClient {
  constructor(config: CantonLedgerConfig);

  // Signer management
  registerSigner(signer: ExternalSigner): void;
  unregisterSigner(signerId: string): boolean;
  getSigner(signerId: string): ExternalSigner | undefined;
  listSigners(): string[];

  // Command submission
  submitCreateCommand<T>(
    command: DamlCommand<T>,
    signerId: string,
    options?: ExternalSigningOptions
  ): Promise<CommandSubmissionResult>;

  submitExerciseCommand<T, R>(
    command: DamlExerciseCommand<T, R>,
    signerId: string,
    options?: ExternalSigningOptions
  ): Promise<CommandSubmissionResult>;
}

ExternalSigner Interface

All signers must implement this interface:

interface ExternalSigner {
  readonly id: string;

  sign(data: Uint8Array): Promise<Signature>;
  getPublicKeyHex(): Promise<string>;
  getPublicKey(): Promise<Uint8Array>;

  getSupportedAlgorithms?(): string[];
}

Built-in Signers

InMemorySigner

For development and testing:

const signer = new InMemorySigner("signer-id", "ES256");
await signer.initialize();

Ed25519Signer

For production use with Ed25519 keys:

const signer = new Ed25519Signer();
// Or restore from private key
const signer = Ed25519Signer.fromPrivateKey(privateKeyHex);

MockHSMSigner

Simulates HSM behavior for testing:

const signer = new MockHSMSigner(
  "hsm-signer",
  "key-id",
  "ES256",
  100 // simulated latency in ms
);
await signer.initialize("PIN-CODE");

Core Concepts

Canton's Prepare/Submit Pattern

Canton uses a server-side transaction preparation pattern:

  1. Prepare: Canton builds the transaction and returns a hash
  2. Sign: Client signs only the hash (no serialization needed)
  3. Submit: Client submits the signature back to Canton
// Canton prepares the transaction
const prepared = await api.prepareTransferPreapproval(params);
// prepared = { transaction: "<base64>", tx_hash: "<hex>" }

// Client signs the hash
const signature = await signer.sign(hexToBytes(prepared.tx_hash));

// Submit the signature
await api.submitTransferPreapproval({
  party_id: params.sender_party_id,
  transaction: prepared.transaction,
  signed_tx_hash: signature.value,
  public_key: publicKeyHex,
});

External Party Management

The library provides complete external party lifecycle management:

// Create a new party with generated Ed25519 keys
const alice = await manager.createParty("alice");

// Import existing party from backup
const bob = await manager.importParty(
  "bob",
  privateKeyHex,
  knownPartyId // optional
);

// List all managed parties
const parties = await manager.listParties();

// Export private key for backup
const privateKey = await manager.exportPartyKey(alice.partyId);

Integration Guide

Web Application Integration

React Example

import { useState, useEffect } from "react";
import { CantonExternalClient, Ed25519Signer } from "@0xsend/external-signing";

function WalletComponent() {
  const [client, setClient] = useState<CantonExternalClient>();
  const [signer, setSigner] = useState<Ed25519Signer>();

  useEffect(() => {
    // Initialize client
    const client = new CantonExternalClient({
      ledgerApiUrl: process.env.CANTON_LEDGER_URL,
      token: localStorage.getItem("auth-token"),
    });

    // Create or restore signer
    const signer = new Ed25519Signer();
    client.registerSigner(signer);

    setClient(client);
    setSigner(signer);
  }, []);

  const handleTransfer = async () => {
    if (!client || !signer) return;

    const command = {
      template: TransferTemplate,
      argument: {
        /* ... */
      },
      party: getCurrentParty(),
    };

    try {
      const result = await client.submitCreateCommand(command, signer.id);
      console.log("Transfer successful:", result.transactionId);
    } catch (error) {
      console.error("Transfer failed:", error);
    }
  };

  return <button onClick={handleTransfer}>Send Transfer</button>;
}

Next.js Integration

// app/lib/canton-client.ts
import { CantonExternalClient } from "@0xsend/external-signing";

let client: CantonExternalClient;

export function getCantonClient() {
  if (!client) {
    client = new CantonExternalClient({
      ledgerApiUrl: process.env.NEXT_PUBLIC_CANTON_URL!,
      // Token handled by middleware
    });
  }
  return client;
}

// app/actions/transfer.ts
("use server");

import { getCantonClient } from "@/lib/canton-client";
import { Ed25519Signer } from "@0xsend/external-signing";

export async function executeTransfer(amount: string, recipient: string) {
  const client = getCantonClient();
  const signer = new Ed25519Signer();

  // ... implement transfer logic
}

Custom Signer Implementation

Implement your own signer for custom requirements:

import { ExternalSigner, Signature } from "@0xsend/external-signing";

class MyCustomSigner implements ExternalSigner {
  readonly id: string;

  constructor(id: string) {
    this.id = id;
  }

  async sign(data: Uint8Array): Promise<Signature> {
    // Your signing logic here
    // e.g., call to HSM, hardware wallet, etc.

    return {
      algorithm: "ES256",
      value: signatureHex,
      publicKey: publicKeyHex,
      keyId: this.id,
    };
  }

  async getPublicKeyHex(): Promise<string> {
    // Return hex-encoded public key
  }

  async getPublicKey(): Promise<Uint8Array> {
    // Return raw public key bytes
  }

  getSupportedAlgorithms(): string[] {
    return ["ES256", "ES384"];
  }
}

Error Handling

The library provides specific error types for different scenarios:

import {
  ExternalSigningError,
  ExternalSigningErrorCode,
} from "@0xsend/external-signing";

try {
  await client.submitCreateCommand(command, signerId);
} catch (error) {
  if (error instanceof ExternalSigningError) {
    switch (error.code) {
      case ExternalSigningErrorCode.SIGNER_NOT_FOUND:
        console.error("Signer not registered");
        break;
      case ExternalSigningErrorCode.SIGNING_FAILED:
        console.error("Signing operation failed");
        break;
      case ExternalSigningErrorCode.TIMEOUT:
        console.error("Signing timed out");
        break;
      // ... handle other error codes
    }
  }
}

Authentication and API Requirements

Important: Admin Authentication Required

External party management endpoints require admin authentication. Regular user tokens will receive 401 Unauthorized errors.

JWT Token Requirements

For Canton localnet with JWKS authentication:

// Get admin token from Keycloak using client credentials
async function getAdminToken(): Promise<string> {
  const response = await fetch(
    "http://auth-dev.cantonwallet.com/realms/localnet/protocol/openid-connect/token",
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "client_credentials",
        client_id: "localnet-validator",
        client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
        audience:
          "https://ledger-api.canton.local https://canton.network.global",
      }),
    }
  );

  const data = await response.json();
  return data.access_token;
}

// Use admin token for external party operations
const adminToken = await getAdminToken();
const manager = new ExternalPartyManager(
  {
    validatorUrl: "http://localhost:45003",
    authToken: adminToken,
  },
  storage
);

Legacy HMAC Authentication (Development Only)

For backwards compatibility with unsafe HMAC tokens:

import { SignJWT } from "jose";

// Only for development environments with SPLICE_APP_UI_UNSAFE=true
async function generateUnsafeAdminToken(): Promise<string> {
  const secret = new TextEncoder().encode("unsafe");

  const token = await new SignJWT({
    sub: "ledger-api-user",
    aud: ["https://ledger-api.canton.local", "https://canton.network.global"],
    scope: "daml_ledger_api",
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("24h")
    .sign(secret);

  return token;
}

Security Considerations

Best Practices

  1. Never expose private keys - Keep them in secure storage
  2. Use hardware security - HSMs or secure enclaves for production
  3. Implement key rotation - Regular key updates
  4. Audit signing requests - Log all signature operations
  5. Validate inputs - Always validate transaction data before signing

Production Checklist

  • Private keys stored in HSM or secure enclave
  • Signing operations require authentication
  • Transaction limits implemented
  • Audit logging enabled
  • Key backup and recovery procedures
  • Regular security audits

Testing

Running Tests

# Unit tests
yarn test src/testxsend/external-signing.test.ts

# Integration tests (requires Canton localnet)
yarn test:integration

# Watch mode
yarn test:watch

Writing Tests

import { createTestSigner } from "@0xsend/external-signing";

describe("My Canton Integration", () => {
  it("should sign and submit transaction", async () => {
    const signer = await createTestSigner("test-signer");
    const client = new CantonExternalClient({
      ledgerApiUrl: "http://localhost:5001/",
    });

    client.registerSigner(signer);

    // ... test your integration
  });
});

Troubleshooting

Common Issues

Ledger URL must end with '/'

// ❌ Wrong
ledgerApiUrl: "http://localhost:5001";

// ✅ Correct
ledgerApiUrl: "http://localhost:5001/";

Signer not found error

// Make sure to register the signer first
client.registerSigner(signer);

Timeout errors

// Increase timeout for slow signers
await client.submitCreateCommand(command, signerId, {
  signingTimeoutMs: 30000, // 30 seconds
});

401 Unauthorized for external party operations

// Use admin token with 'ledger-api-user' subject
const adminToken = await generateAdminToken();

Development

Project Structure

packages/canton-external-signing/
├── src/
│   ├── api/              # Canton API integrations
│   ├── client/           # Client implementations
│   ├── party/            # External party management
│   ├── signers/          # Signer implementations
│   ├── storage/          # Storage interfaces
│   ├── types/            # TypeScript types
│   ├── utils/            # Utilities
│   └── test/             # Test files
├── README.md
└── package.json

Building

# Type checking
yarn typecheck

# Run linter
yarn lint

# Build (if needed)
yarn build

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

This package is part of the Canton monorepo and follows the same license terms.

Support

For issues and questions:

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Declared dependencies 16

Native requirements
16 direct targetsNot calculated transitive count
Package / targetRequirementRelationship
zod
Environment
{
  "local_name": "zod",
  "alias": false,
  "peer_metadata": null
}
^4.1.13runtime
jose
Environment
{
  "local_name": "jose",
  "alias": false,
  "peer_metadata": null
}
^6.0.11runtime
debug
Environment
{
  "local_name": "debug",
  "alias": false,
  "peer_metadata": null
}
^4.4.1runtime
argon2
Environment
{
  "local_name": "argon2",
  "alias": false,
  "peer_metadata": null
}
^0.41.1runtime
lodash
Environment
{
  "local_name": "lodash",
  "alias": false,
  "peer_metadata": null
}
^4.17.21runtime
@daml/types
Environment
{
  "local_name": "@daml/types",
  "alias": false,
  "peer_metadata": null
}
3.3.0-snapshot.20250502.13767.0.v2fc6c7e2runtime
@daml/ledger
Environment
{
  "local_name": "@daml/ledger",
  "alias": false,
  "peer_metadata": null
}
3.3.0-snapshot.20250502.13767.0.v2fc6c7e2runtime
@noble/curves
Environment
{
  "local_name": "@noble/curves",
  "alias": false,
  "peer_metadata": null
}
^2.0.1runtime
@noble/hashes
Environment
{
  "local_name": "@noble/hashes",
  "alias": false,
  "peer_metadata": null
}
^2.0.1runtime
vitest
Environment
{
  "local_name": "vitest",
  "alias": false,
  "peer_metadata": null
}
^3.2.4development
esbuild
Environment
{
  "local_name": "esbuild",
  "alias": false,
  "peer_metadata": null
}
^0.27.0development
typescript
Environment
{
  "local_name": "typescript",
  "alias": false,
  "peer_metadata": null
}
^5.8.3development
@types/node
Environment
{
  "local_name": "@types/node",
  "alias": false,
  "peer_metadata": null
}
^24.0.12development
@types/debug
Environment
{
  "local_name": "@types/debug",
  "alias": false,
  "peer_metadata": null
}
^4development
@types/lodash
Environment
{
  "local_name": "@types/lodash",
  "alias": false,
  "peer_metadata": null
}
^4.17.20development
@canton/test-utils
Environment
{
  "local_name": "@canton/test-utils",
  "alias": false,
  "peer_metadata": null
}
3.0.0development

Distribution files

1 collected
Distribution 1

Source distribution

Source file
Checksums from source
{
  "shasum": "18077cd354a8cecb57ef7fcda7458616e7c09966",
  "tarball": "https://registry.npmjs.org/@0xsend/external-signing/-/external-signing-0.4.1.tgz",
  "fileCount": 27,
  "integrity": "sha512-u6OlBpg5Gx8qUB33UqOOEi66YIifUI0yI/Tk0IfwdO6IUOCPSwGyi2Ig+qianB9NPZU7jYbcl4t9VZ0lq3Ko7w==",
  "signatures": [
    {
      "sig": "MEQCIAyejC9niLbYhmSmKPqWZUI0mtkv0dhf7jDiwhhlcGNpAiA42jZYUIUSL0jv7d0hHEOXYaSlMrzsWcTBguUrTfJ5Yw==",
      "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"
    },
    {
      "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
      "sig": "MEQCIHqfR5N1sK9vkfjjouzARfAsP8xwB42LMAMQRFq+y0ziAiBO1+zXH1TEDWIqyaM4bw32wDzajgNuEO3hig9BraIP0A=="
    }
  ],
  "unpackedSize": 127306
}

Repository

PENDING
canton-foundation/canton-monorepo

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 21 Oct, 09:45 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package_version:730e36dbc5b375399cc3ffd3fbdf34d866da05d2ea0c269c0367af6be291f85c

Other collected versions

0.4.017 Jul0.3.029 May0.2.226 Apr0.2.126 Apr0.2.025 AprView all collected versions →