Developer Guide

Track each EVM source event: Use the receipt transaction hash, source chain ID and actual log index (ethers.Log.index). Supply sourceChainId and sourceEventIndex together on hash lookup. Hash-only lookup still returns 409 on ambiguity. A legacy row with unresolved event identity cannot satisfy event selection; historical missing events require explicit backfill. These changes describe the migration-022 application version, not the deployment state or historical impact.

Use the public testnet for integration testing. Its API, contract addresses and token registry are independent of other environments. Never combine an ID or contract address from one deployment with another deployment's API.

Track Bridge Transaction

The API has two distinct lookups:

  • GET /api/v1/transactions/{id} takes the numeric bridge-record ID, returned as a string.
  • GET /api/v1/transactions/hash/{sourceTxHash}?sourceChainId={chainId}&sourceEventIndex={logIndex} selects an EVM source event.
  • GET /api/v1/transactions/hash/{sourceTxHash} takes the source-chain transaction hash, not the destination mint/unlock hash.

Both return { "success": true, "data": { ... } }. A 404 can mean the source event has not been indexed yet, or that the hash belongs to another environment. It does not by itself mean the transfer failed.

The following Node.js 22+ example first validates the requested event, then polls /transactions/{id} using that stable row ID. Every response must preserve the ID, source hash, chain, type, nonce and EVM source chain ID; inclusion coordinates such as the log index may change on reorg. A 404 after resolution retries only that ID. A re-anchor before the first validated lookup may leave the original selector unresolved and require separate reconciliation.

Polling has an overall timeout and per-request timeout. Network errors are surfaced to the caller. A submitted destination is provisional; only completed returns success.

// Public-testnet source-event lookup, then stable row-ID polling. Requires Node.js 22+.
export async function trackBridgeTransaction(sourceTxHash, {
  expectedEvent,
  apiBase = 'https://api.testnet.astralbeam.io/api/v1',
  signal = AbortSignal.timeout(60 * 60 * 1000),
  pollMs = 10000,
  fetchImpl = fetch,
  sleep = async (ms, signal) => {
    const { setTimeout } = await import('node:timers/promises');
    await setTimeout(ms, undefined, { signal });
  },
  onStatus = tx => console.log(`Status: ${tx.status}`),
} = {}) {
  if (!/^(?:0x)?[a-fA-F0-9]{64}$/.test(sourceTxHash)) {
    throw new Error('Expected a source-chain transaction hash, not a bridge record ID');
  }
  // Obtain these from the intended source event, never from this hash lookup.
  if (!expectedEvent || typeof expectedEvent.sourceChain !== 'string' || !expectedEvent.sourceChain ||
      !['lock','burn','unlock','lock_native','mint_wrapped','burn_wrapped','unlock_native'].includes(expectedEvent.txType) ||
      typeof expectedEvent.nonce !== 'string' || !/^(0|[1-9][0-9]*)$/.test(expectedEvent.nonce)) {
    throw new Error('Expected event requires sourceChain, txType and decimal-string nonce');
  }
  const expected = {...expectedEvent};
  const evmSource = expected.sourceChain !== 'casper';
  if (evmSource && (!Number.isSafeInteger(expected.sourceChainId) || expected.sourceChainId <= 0 ||
      !Number.isSafeInteger(expected.sourceEventIndex) || expected.sourceEventIndex < 0)) {
    throw new Error('EVM event requires sourceChainId and actual receipt sourceEventIndex');
  }
  const selector = evmSource ? `?sourceChainId=${expected.sourceChainId}&sourceEventIndex=${expected.sourceEventIndex}` : '';
  const expectedHash = sourceTxHash.replace(/^0x/, '').toLowerCase();
  let recordId;
  for (;;) {
    signal.throwIfAborted();
    let response = await fetchImpl(
      recordId ? `${apiBase}/transactions/${recordId}` :
        `${apiBase}/transactions/hash/${encodeURIComponent(sourceTxHash)}${selector}`,
      { signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) },
    );
    // The API lookup is prefix-sensitive: Casper normally omits 0x, EVM uses it.
    if (!recordId && response.status === 404) {
      signal.throwIfAborted();
      const alternate = sourceTxHash.startsWith('0x') ? sourceTxHash.slice(2) : `0x${sourceTxHash}`;
      response = await fetchImpl(
        `${apiBase}/transactions/hash/${encodeURIComponent(alternate)}${selector}`,
        { signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) },
      );
    }
    // Discovery or row availability may lag. Once resolved, never switch rows.
    if (response.status === 404) {
      await sleep(pollMs, signal);
      continue;
    }
    const body = await response.json();
    if (!response.ok || body.success !== true || !body.data) {
      throw new Error(body.error || `API request failed: HTTP ${response.status}`);
    }
    const tx = body.data;
    if (typeof tx.id !== 'string' || !/^[1-9][0-9]*$/.test(tx.id) || (recordId && tx.id !== recordId) ||
        typeof tx.sourceTxHash !== 'string' || tx.sourceTxHash.replace(/^0x/, '').toLowerCase() !== expectedHash ||
        tx.sourceChain !== expected.sourceChain || tx.txType !== expected.txType || tx.nonce !== expected.nonce ||
        (evmSource && (tx.evmChainId !== expected.sourceChainId ||
          !Number.isSafeInteger(tx.sourceEventIndex) || tx.sourceEventIndex < 0 ||
          (!recordId && tx.sourceEventIndex !== expected.sourceEventIndex)))) {
      throw new Error('Source event mismatch: response does not identify the requested obligation');
    }
    // Inclusion coordinates can change on reorg; the row and source identity cannot.
    recordId = tx.id;
    // This is the API status of the matched record, not independent on-chain proof.
    onStatus(tx);
    if (tx.status === 'completed') return tx;
    if (tx.status === 'failed') throw new Error(tx.errorMessage || 'Bridge transfer failed');
    // A submitted destination hash is provisional, not completed finality.
    await sleep(pollMs, signal);
  }
}

Pass the source hash from your wallet receipt. For EVM, independently decode each intended Locked/WrappedBurned log and pass { sourceChain, sourceChainId, sourceEventIndex: log.index, txType, nonce: decodedNonce.toString() } as expectedEvent. Track each sibling separately. transactionIndex and the deprecated sourceLogIndex are not event selectors. A 409 is unresolved identity, never completion. To stop polling early, pass an AbortController's signal. For browser integrations, replace the Node timer helper with a cancellable browser timer.

See Transactions for filters, pagination, status and amount semantics.

Lock Tokens (EVM to Casper)

Resolve the locker from the public-testnet contract reference and the token from the Tokens API. Confirm the connected wallet is on the selected source chain before approval or locking.

This example uses ethers 6 and an account recipient. casperAccountHash must be the 32-byte account hash, not a Casper public key and not a contract/package hash. Obtain the account hash from your Casper wallet/explorer or derive it from the public key using your Casper SDK. Padding or truncating public-key bytes is not account-hash derivation. routeId is the token's lock-mint route on that locker (read it from backingDomain(domainId).routeIds).

import { ethers } from 'ethers';

const CASPER_ACCOUNT = 2; // recipient kind; 3 is a contract-package hash

export function accountRecipientBytes(casperAccountHash) {
  const hex = casperAccountHash.replace(/^account-hash-/, '').replace(/^0x/, '');
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
    throw new Error('Expected a 32-byte Casper account hash, not a public key');
  }
  return ethers.getBytes(`0x${hex}`);
}

async function lockTokens(signer, lockerAddress, routeId, tokenAddress, amountRaw, casperAccountHash) {
  const locker = new ethers.Contract(lockerAddress, [
    'function lock(bytes32 routeId, uint256 amount, uint8 recipientKind, bytes32 recipientValue) payable returns (bytes32 transferId)',
    'function bridgeFee() view returns (uint256)',
  ], signer);
  const token = new ethers.Contract(tokenAddress, [
    'function approve(address spender, uint256 amount) returns (bool)',
  ], signer);
  const recipient = accountRecipientBytes(casperAccountHash);
  await (await token.approve(lockerAddress, amountRaw)).wait();
  const fee = await locker.bridgeFee(); // exact; not refunded after commitment
  const tx = await locker.lock(routeId, amountRaw, CASPER_ACCOUNT, recipient, { value: fee });
  return await tx.wait();
}

amountRaw is an integer in the token's smallest units. Token-specific allowance policies may require resetting an existing allowance to zero before approval. For typed package recipients and the complete current event signatures, use the EVM contract reference. Use recipient kind 3 for package recipients.

Token Balances

The bridge API reports registered tokens and bridge liquidity, not wallet balances. Query balanceOf on EVM, or use your Casper SDK's CEP-18 balance support for the deployed token package. Read decimals and addresses from the selected environment's token registry. Keep large integer balances as bigint or decimal strings, not JavaScript floating-point numbers.

Development

git clone https://github.com/make-software/astralbeam-core.git
cd astralbeam-core
npm ci --prefix cli
npm run build --prefix cli

See Local Development, Testing, and Contributing. Keep keys in local configuration, never in application bundles or documentation.

results matching ""

    No results matching ""