Skip to content
LogoLogo

Guardian Signatures

Verifying the integrity of generated addresses

Included in the address generation response are signatures from the Unit guardians. Alongside the Public Keys of the Unit guardians, these signatures may be used to verify the integrity of the generated address.

Users are encouraged to verify their addresses before making a deposit or withdrawal. Developers building on Unit are likewise encouraged to incorporate address verification in their interfaces & use cases.

The example uses Web Crypto in a secure browser context or Node.js 22+. Pass mainnet or testnet explicitly to select the trusted guardian keys for the API you called. Verification requires signatures from at least two distinct guardians in that environment.

Build the proposal from the destination address, chains, asset, and coin type you requested, together with the generated address returned by the API. Check success before using that address. Keep the trusted guardian keys in your application configuration.

guardian-signatures.ts
// Constants
export const TESTNET_GUARDIAN_NODES = [
  {
    nodeId: 'node-1',
    publicKey:
      '04bab844e8620c4a1ec304df6284cd6fdffcde79b3330a7bffb1e4cecfee72d02a7c1f3a4415b253dc8d6ca2146db170e1617605cc8a4160f539890b8a24712152',
  },
  {
    nodeId: 'hl-node-testnet',
    publicKey:
      '04502d20a0d8d8aaea9395eb46d50ad2d8278c1b3a3bcdc200d531253612be23f5f2e9709bf3a3a50d1447281fa81aca0bf2ac2a6a3cb8a12978381d73c24bb2d9',
  },
  {
    nodeId: 'field-node',
    publicKey:
      '04e674a796ff01d6b74f4ee4079640729797538cdb4926ec333ce1bd18414ef7f22c1a142fd76dca120614045273f30338cd07d79bc99872c76151756aaec0f8e8',
  },
];
 
export const MAINNET_GUARDIAN_NODES = [
  {
    nodeId: 'unit-node',
    publicKey:
      '04dc6f89f921dc816aa69b687be1fcc3cc1d48912629abc2c9964e807422e1047e0435cb5ba0fa53cb9a57a9c610b4e872a0a2caedda78c4f85ebafcca93524061',
  },
  {
    nodeId: 'hl-node',
    publicKey:
      '048633ea6ab7e40cdacf37d1340057e84bb9810de0687af78d031e9b07b65ad4ab379180ab55075f5c2ebb96dab30d2c2fab49d5635845327b6a3c27d20ba4755b',
  },
  {
    nodeId: 'field-node',
    publicKey:
      '04ae2ab20787f816ea5d13f36c4c4f7e196e29e867086f3ce818abb73077a237f841b33ada5be71b83f4af29f333dedc5411ca4016bd52ab657db2896ef374ce99',
  },
];
 
const GUARDIAN_SIGNATURE_THRESHOLD = 2;
 
interface Proposal {
  destinationAddress: string;
  destinationChain: string;
  asset: string;
  address: string;
  sourceChain: string;
  coinType?: string;
  keyType?: string;
}
 
interface VerificationResult {
  success: boolean;
  verifiedCount: number;
  errors?: string[];
  verificationDetails?: { [nodeId: string]: boolean };
}
 
function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {
  const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
  // accepts complete hexadecimal byte pairs and rejects empty or malformed keys
  if (!/^(?:[0-9a-f]{2})+$/i.test(cleanHex)) {
    throw new Error('Invalid guardian public key hex');
  }
  const bytes = new Uint8Array(cleanHex.length / 2);
  for (let index = 0; index < bytes.length; index++) {
    bytes[index] = Number.parseInt(cleanHex.slice(index * 2, index * 2 + 2), 16);
  }
  return bytes;
}
 
function legacyProposalToPayload(
  nodeId: string,
  proposal: Proposal,
): Uint8Array<ArrayBuffer> {
  const payloadString = `${nodeId}:${[
    proposal.destinationAddress,
    proposal.destinationChain,
    proposal.asset,
    proposal.address,
    proposal.sourceChain,
    'deposit',
  ].join('-')}`;
  return new TextEncoder().encode(payloadString);
}
 
function newProposalToPayload(
  nodeId: string,
  proposal: Proposal,
): Uint8Array<ArrayBuffer> {
  const payloadString = `${nodeId}:${[
    'user',
    proposal.coinType,
    proposal.destinationChain,
    proposal.destinationAddress,
    proposal.address,
  ].join('-')}`;
  return new TextEncoder().encode(payloadString);
}
 
async function processGuardianNodes(nodes: { nodeId: string; publicKey: string }[]) {
  const processed = [];
  for (const node of nodes) {
    try {
      const publicKeyBytes = hexToBytes(node.publicKey);
      if (publicKeyBytes.length !== 65 || publicKeyBytes[0] !== 0x04) {
        throw new Error(`Invalid public key format for node ${node.nodeId}`);
      }
      const publicKey = await crypto.subtle.importKey(
        'raw',
        publicKeyBytes,
        { name: 'ECDSA', namedCurve: 'P-256' },
        true,
        ['verify'],
      );
      processed.push({ nodeId: node.nodeId, publicKey });
    } catch (error) {
      console.error(`Failed to process node ${node.nodeId}:`, error);
      throw new Error(
        `Node processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
      );
    }
  }
  return processed;
}
 
async function verifySignature(
  publicKey: CryptoKey,
  message: Uint8Array<ArrayBuffer>,
  signature: string,
): Promise<boolean> {
  try {
    const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
    if (sigBytes.length !== 64) {
      console.warn('Invalid signature length:', sigBytes.length);
      return false;
    }
    return await crypto.subtle.verify(
      {
        name: 'ECDSA',
        hash: { name: 'SHA-256' },
      },
      publicKey,
      sigBytes,
      message,
    );
  } catch (error) {
    console.error('Signature verification failed:', error);
    return false;
  }
}
 
export async function verifyDepositAddressSignatures(
  signatures: { [nodeId: string]: string },
  proposal: Proposal,
  environment: 'mainnet' | 'testnet',
): Promise<VerificationResult> {
  try {
    if (environment !== 'mainnet' && environment !== 'testnet') {
      throw new Error('Select mainnet or testnet explicitly');
    }
    const guardianNodes =
      environment === 'mainnet'
        ? MAINNET_GUARDIAN_NODES
        : TESTNET_GUARDIAN_NODES;
    const processedNodes = await processGuardianNodes(guardianNodes);
    let verifiedCount = 0;
    const errors: string[] = [];
    const verificationDetails: { [nodeId: string]: boolean } = {};
 
    await Promise.all(
      processedNodes.map(async (node) => {
        try {
          if (!signatures[node.nodeId]) {
            verificationDetails[node.nodeId] = false;
            return;
          }
          let isVerified = false;
 
          if (proposal.coinType !== 'ethereum') {
            const legacyPayload = legacyProposalToPayload(node.nodeId, proposal);
            isVerified = await verifySignature(
              node.publicKey,
              legacyPayload,
              signatures[node.nodeId],
            );
 
            if (!isVerified) {
              const newPayload = newProposalToPayload(node.nodeId, proposal);
              isVerified = await verifySignature(
                node.publicKey,
                newPayload,
                signatures[node.nodeId],
              );
            }
          } else {
            const payload = newProposalToPayload(node.nodeId, proposal);
            isVerified = await verifySignature(
              node.publicKey,
              payload,
              signatures[node.nodeId],
            );
          }
 
          verificationDetails[node.nodeId] = isVerified;
          if (isVerified) verifiedCount++;
        } catch (error) {
          errors.push(
            `Verification failed for node ${node.nodeId}: ${
              error instanceof Error ? error.message : 'Unknown error'
            }`,
          );
          verificationDetails[node.nodeId] = false;
        }
      }),
    );
 
    return {
      success: verifiedCount >= GUARDIAN_SIGNATURE_THRESHOLD,
      verifiedCount,
      errors: errors.length > 0 ? errors : undefined,
      verificationDetails,
    };
  } catch (error) {
    return {
      success: false,
      verifiedCount: 0,
      errors: [
        `Global verification error: ${error instanceof Error ? error.message : 'Unknown error'}`,
      ],
      verificationDetails: {},
    };
  }
}

For example, call await verifyDepositAddressSignatures(response.signatures, proposal, 'mainnet') for a response from https://api.hyperunit.xyz. Use testnet for https://api.hyperunit-testnet.xyz.