Integrating with an Exchange

This document is intended to guide developers through the integration of the XYM token into an Exchange platform. It contains recommendations on how to set up accounts, listen for deposits, and create withdrawals as well as code examples ready to be adopted.

The code examples shared use the Symbol SDK for TypeScript, but can be ported to other available SDKs since all of them share the same design principles. If there is no SDK supported for a required programming language, you may still be able to integrate by connecting directly via Symbol’s REST API.

Integration overview

There are many ways to design an exchange. This guide is based on how to support XYM deposits and withdrawals in an exchange that follows a central wallet approach.

Please note that this design is not particularly recommend over others. However, its simplified architecture is a good showcase for Symbol’s set of features involved in integrating with an Exchange. A different approach, for example, would be to use a different wallet for each user.

../../_images/exchange-integration-overview.png

Fig. 1: General design diagram of the central wallet approach.

The main components of this architecture are described next.

Components

Central wallet

The exchange owns a Symbol account where all the user’s deposits and withdrawals occur. The keys to this account need to be on an online machine, so this is also commonly called the Hot wallet. This account only has the necessary amount of XYM for daily use (withdrawals and deposits), since it is the account most exposed to attacks.

Cold wallet

Cold wallet(s) hold a certain threshold for the pool of XYM. These accounts should be created and remain in a setup with no internet connection. Transactions issued from cold wallets must be signed offline and announced to the network using another device. It is advisable as well that cold wallets are set up with multisig accounts.

Unique User ID

In the proposed architecture, each user is identified by a Unique User IDentifier (UUID) on the exchange’s database. A user will deposit to the central wallet with their UUID attached as the message of the transaction (called sometimes the memo). The UUID is only shown in the user’s dashboard during the deposit confirmation.

One of the drawbacks of this design is that many users are not used to having a message attached to their transactions. If they forget to attach the UUID or attach a wrong UUID, it will lead to receiving lots of support tickets concerning “lost funds”.

Caution

Symbol’s Transfer transactions can hold an arbitrary message up to 1023 bytes long but the first byte is treated specially by the Symbol SDK.

This can be a source of confusion because the receiver of a transaction does not know if the message was generated by the Symbol SDK or otherwise (for example accessing the REST gateway), so it does not know if the first byte must be treated specially or not.

To avoid any issue, the following measures must always be enforced:

  • Always start messages with a byte in the 32 to 128 range (this is the standard ASCII printable range).

  • Always ignore any received initial byte outside the 32 to 128 range.

Follow these rules, regardless of whether you use the Symbol SDK or not to generate and parse transfer transactions.

Exchange Server

This machine is constantly listening for user’s withdraw requests, and monitors the blockchain to detect user deposits into the Exchange Central Wallet. As explained in the rest of this document, it maintains the database updated and announces any required transaction.

Exchange Database

All the user’s funds are merged together in the Exchange’s wallets. This database keeps track of the amount of tokens each individual user holds. It also records all processed transactions, for record-keeping and to avoid processing the same transaction more than once.

Running a node

Although not absolutely necessary, it is recommended that Exchanges deploy their own Symbol node to communicate with the rest of the network. Since each node automatically connects to several other nodes on the network, this approach is more robust than accessing the network always through the same public node, which might become unavailable.

If you are unable to run your own node, you can choose one from the list provided by the Statistics Service.

See the different guides about deploying Symbol nodes and make sure you create an API node.

Accounts setup

Exchanges can create the central and cold wallets by downloading the official Symbol Desktop Wallet for Windows, Linux or Mac.

Every wallet has assigned an account (a deposit box that holds tokens, which can only be transferred with the appropriate private key).

Caution

The private key must be kept secret at all times and must not be shared. Losing the private key means losing access to an account’s funds, so make sure it is securely backed up.

It is advisable to turn central and cold wallets into multisig accounts to add two-factor authentication. The cosignatories of the multisig account become the account managers, so no transaction can be announced from the multisig account without the cosignatories’ approval. Symbol’s current implementation of multisig is “M-of-N” meaning that M out of the total N cosignatories of an account need to approve a transaction for it to be announced.

Caution

Multisig accounts are a powerful yet dangerous tool. If access to some cosignatory account is lost and the minimum approval is not reached (the M above), access to the multisig account can be permanently lost. Always configure multisig accounts with caution.

To strengthen security, extra account restrictions can be added to the Exchange’s accounts, like blocking announcing or receiving transactions given a series of rules.

The XYM token

The native currency of the Symbol network is named XYM. The token is used to pay for transactions and service fees, which are used as well to provide an incentive for those participants who secure the network and run the infrastructure.

Tokens can be divided up to divisibility decimal places. Amounts given without decimals are called absolute, whereas when decimals are used amounts are called relative. For example, when divisibility is 6, 1 relative token corresponds to 1’000’000 absolute tokens, and the smallest token is 0.000001 relative units. The smallest absolute unit is always 1, regardless of the divisibility.

These are the properties of XYM:

Property

Value

Description

ID

0x6BED913FA20223F8

Token unique identifier

Alias

symbol.xym

Friendly name for the token

Initial supply

7’842’928’625 (relative)

Initial amount of token units in circulation

Max supply

8’999’999’999 (relative)

Maximum amount of token units in circulation after inflation is applied

Divisibility

6

This means that the smallest fraction of the token is 0.000001 (relative).

Duration

0

Token does not expire

Supply mutable

False

Token supply cannot be altered

Transferable

True

Token can be transferred between arbitrary accounts

Restrictable

False

Token creator cannot restrict which accounts can transact with the mosaic

Caution

The XYM token can be referred to through its native token ID or its friendlier alias symbol.xym, which has an ID on itself.

On MAINNET, these IDs are 0x6BED913FA20223F8 (mosaic ID) and 0xE74B99BA41F4AFEE (alias ID).

Always treat these two IDs as equivalent.

Aggregates

Symbol has a novel feature called Aggregate Transaction which allows bundling multiple inner transactions into a single one.

Therefore, when monitoring incoming Transfer transactions you must remember to also look inside all Aggregate transactions (Both Aggregate Complete and Aggregate Bonded transactions). The example code given below shows a way of achieving this.

Caution

When Aggregate transactions are not monitored, inner transfer transactions are not detected, leading to lots of reports of lost funds.

This is specially relevant for multi-signature accounts, where all transactions are wrapped in an Aggregate.

Avoiding rollbacks

This is a classic conflict in blockchain technology: On one hand, if transactions are accepted too quickly, they might need to be reverted later on in the event of a network fork. On the other hand, waiting for too long is inconvenient for users.

There are two ways of dealing with this in Symbol:

Using Finalization

Symbol implements Finalization, a process that guarantees that blocks are immutable and therefore transactions are secure.

To know if a block has been finalized, check the latestFinalizedBlock property in the /chain/info endpoint. All blocks with a height lower than (or equal to) latestFinalizedBlock.height are finalized and are therefore immutable.

On average, blocks are finalized after 5 minutes, in the absence of network problems.

Using a fixed wait

To have faster response times, one must ignore finalization and accept the risk that comes with this: Unfinalized blocks have a probability of being reverted, which decreases over time but is never zero until the block is finalized.

The procedure, which is common in blockchains which do not support finalization, is to wait for a few blocks to be validated (added to the blockchain) before accepting a transaction.

The amount of blocks to wait for depends on the risk one wants to accept. The recommendation for Symbol is 20 blocks (about 10 minutes, regardless of network conditions, because Finalization will almost always happen during this time).

In summary

  • Waiting for a fixed amount of blocks leads to consistent confirmation times, but has the risk that confirmed transactions might be reverted.

  • Waiting for finalization has variable confirmation times (5 minutes on average) but has zero rollback risk.

Deadlines

An added problem caused by rollbacks is that transactions might expire in the process of resolving a network fork.

A bit of context is required here. Transactions are not allowed to remain unconfirmed in the network forever, as this would pose a significant strain on the network’s resources. Instead, all transactions have a deadline, and are automatically disposed of when the deadline arrives.

Users are free to use any deadline they want for their transactions, between now and 6h into the future (48h for Aggregate bonded transactions).

Transactions which are about to expire are delicate because, even if they get confirmed and are added to the blockchain, a rollback could send them back to the unconfirmed state and their deadline could expire before they are confirmed again.

Therefore, it is recommended that:

  • Incoming transactions with a deadline less than 1h into the future are rejected with a warning message, for example:

    Transaction is too close to expiration to be safely accepted.

  • Exchanges avoid using transactions with short lifespans.

  • Exchanges actively encourage their customers to avoid using transactions with short lifespans.

The example code

This guide shows snippets of code to exemplify the different processes. All snippets are based on the same program that can be found here. A few notes on this example program:

  • It uses a fake DBService object that simulates the Exchange database. Calls to this object should obviously be replaced by the actual Exchange infrastructure in production code. For simplicity these calls are synchronous but they could be made asynchronously too.

  • No error handling is performed at all. Use mechanisms like try {} catch where appropriate in production code.

  • Finally, besides the snippets shown in the guide, the complete program also contains auxiliary code (like polling loops) in order to make it runnable and self-sufficient. This auxiliary code is not meant to be used as an inspiration at all, it is just there for convenience.

Deposits

../../_images/exchange-integration-deposit.png

Fig. 2: Deposit process.

Users perform deposits by announcing a regular transfer transaction using their wallet, moving the funds from their account directly to the Exchange Central Wallet. Since the transfer is handled entirely by the blockchain, the funds will be added to the Exchange Central Wallet without the Exchange’s mediation, and this poses some problems:

  • The intended recipient of the transaction must be determined. This is done by attaching the user’s UUID as the transaction’s message.

  • The fact that a transaction has happened must be timely detected to update the user’s account on the Exchange.

  • Transactions must be finalized to be 100% sure that they will not be rolled back.

The code proposed next addresses all these issues by monitoring the blockchain.

Monitoring

The blockchain is polled periodically and all incoming transactions since last poll are processed in a batch:

  1. All Transfer transactions added to the blockchain since the last check and up to the latest finalized block are examined, looking for the ones destined to the Central Exchange Wallet. This can be done efficiently with a single Symbol API call.

    • Transfer transactions embedded in Aggregate Complete and Aggregate Bonded transactions must also be examined (see the Aggregates section above). This is handled in the example code by the embedded: true parameter in the searchConfirmedTransactions call.

    • If Finalization is not desired (see the Avoiding rollbacks section above) you can search up to 20 blocks before the current chain height, for example.

  2. Filter out transactions that:

    1. Have no message or the message does not correspond to an existing UUID.

    2. Do not contain tokens, or the token is not symbol.xym.

    3. Have already been processed (as a security measure).

  3. The remaining transactions are then processed:

    1. The tokens are added to the user’s account in the database.

    2. The transaction is marked as processed by adding its hash to the database

  4. Store the last height that has been processed and wait for the next polling period.

The code snippet, using Symbol’s TypeScript SDK is this:

  // Mocks a database service
  const dbService = new DBServiceImpl();

  const config: ExchangeSymbolConfig = {
    // Replace with your node URL
    apiUrl: 'NODE_URL',
    // Use MAIN_NET or TEST_NET
    networkType: NetworkType.TEST_NET,
    // Replace with value from http://<API-NODE-URL>:3000/network/properties
    networkGenerationHashSeed:
      '3B5E1FA6445653C971A50687E75E6D09FB30481055E3990C84B25E9222DC1155',
    // Replace with the account central address
    centralAccountAddress: Address.createFromRawAddress(
      'TAOXUJOTTW3W5XTBQMQEX3SQNA6MCUVGXLXR3TA',
    ),
    // Use 6BED913FA20223F8 for MAIN_NET or 091F837E059AE13C for TEST_NET
    tokenId: new MosaicId('091F837E059AE13C'),
    tokenAlias: new NamespaceId('symbol.xym'),
    tokenDivisibility: 6,
    requiredConfirmations: 20,
    useFinalization: true,
  };

  const repositoryFactory = new RepositoryFactoryHttp(config.apiUrl);
  const chainHttp = repositoryFactory.createChainRepository();
  const transactionHttp = repositoryFactory.createTransactionRepository();
  const transactionPaginationStreamer = new TransactionPaginationStreamer(
    transactionHttp,
  );

  // Get current chain height and latest finalized block
  const {
    height: currentHeight,
    latestFinalizedBlock: finalizationBlock,
  } = await chainHttp.getChainInfo().toPromise();
  const maxHeight = config.useFinalization
    ? finalizationBlock.height
    : currentHeight.subtract(UInt64.fromUint(config.requiredConfirmations));

  // 1. Look for confirmed transactions destined to the central account address,
  // in the desired block height range.
  const searchCriteria = {
    group: TransactionGroup.Confirmed,
    recipientAddress: config.centralAccountAddress,
    embedded: true,
    order: Order.Asc,
    type: [TransactionType.TRANSFER],
    fromHeight: dbService.getLastProcessedHeight(),
    toHeight: maxHeight,
  } as TransactionSearchCriteria;

  const data = await transactionPaginationStreamer
    .search(searchCriteria)
    .pipe(toArray() as any)
    .toPromise();

  // 2. Exclude invalid transactions
  const results = (data as TransferTransaction[]).filter((transaction) => {
    const transactionInfo = transaction.transactionInfo;
    if (!transactionInfo) return false;
    const transactionIndex = transactionInfo.index;
    const transactionHash =
      transactionInfo instanceof AggregateTransactionInfo
        ? transactionInfo.aggregateHash
        : transactionInfo.hash ?? '';

    return (
      // 2.a
      dbService.existsUser(transaction.message.payload) &&
      // 2.b
      transaction.mosaics.length === 1 &&
      (transaction.mosaics[0].id.toHex() === config.tokenId.toHex() ||
        transaction.mosaics[0].id.toHex() === config.tokenAlias.toHex()) &&
      // 2.c
      !dbService.existsTransaction(transactionHash, transactionIndex)
    );
  });

  // 3. Record the valid deposits in the exchange database
  results.forEach((transaction) => {
    const transactionInfo = transaction.transactionInfo;
    if (!transactionInfo) return;
    const transactionHash =
      transactionInfo instanceof AggregateTransactionInfo
        ? transactionInfo.aggregateHash
        : transactionInfo.hash ?? '';
    const transactionIndex = transactionInfo.index;
    const amount =
      transaction.mosaics[0].amount.compact() /
      Math.pow(10, config.tokenDivisibility);
    dbService.recordDeposit(
      transaction.message.payload,
      amount,
      transactionHash,
      transactionIndex,
    );
  });

  // 4. Store the last height that has been processed
  dbService.setLastProcessedHeight(maxHeight);
  // Mocks a database service
  const dbService = new DBServiceImpl_1.DBServiceImpl();
  const config = {
    // Replace with your node URL
    apiUrl: 'NODE_URL',
    // Use MAIN_NET or TEST_NET
    networkType: symbol_sdk_1.NetworkType.TEST_NET,
    // Replace with value from http://<API-NODE-URL>:3000/network/properties
    networkGenerationHashSeed:
      '3B5E1FA6445653C971A50687E75E6D09FB30481055E3990C84B25E9222DC1155',
    // Replace with the account central address
    centralAccountAddress: symbol_sdk_1.Address.createFromRawAddress(
      'TAOXUJOTTW3W5XTBQMQEX3SQNA6MCUVGXLXR3TA',
    ),
    // Use 6BED913FA20223F8 for MAIN_NET or 091F837E059AE13C for TEST_NET
    tokenId: new symbol_sdk_1.MosaicId('091F837E059AE13C'),
    tokenAlias: new symbol_sdk_1.NamespaceId('symbol.xym'),
    tokenDivisibility: 6,
    requiredConfirmations: 20,
    useFinalization: true,
  };
  const repositoryFactory = new symbol_sdk_1.RepositoryFactoryHttp(
    config.apiUrl,
  );
  const chainHttp = repositoryFactory.createChainRepository();
  const transactionHttp = repositoryFactory.createTransactionRepository();
  const transactionPaginationStreamer = new symbol_sdk_1.TransactionPaginationStreamer(
    transactionHttp,
  );
  // Get current chain height and latest finalized block
  const {
    height: currentHeight,
    latestFinalizedBlock: finalizationBlock,
  } = await chainHttp.getChainInfo().toPromise();
  const maxHeight = config.useFinalization
    ? finalizationBlock.height
    : currentHeight.subtract(
        symbol_sdk_1.UInt64.fromUint(config.requiredConfirmations),
      );
  // 1. Look for confirmed transactions destined to the central account address,
  // in the desired block height range.
  const searchCriteria = {
    group: symbol_sdk_1.TransactionGroup.Confirmed,
    recipientAddress: config.centralAccountAddress,
    embedded: true,
    order: symbol_sdk_1.Order.Asc,
    type: [symbol_sdk_1.TransactionType.TRANSFER],
    fromHeight: dbService.getLastProcessedHeight(),
    toHeight: maxHeight,
  };
  const data = await transactionPaginationStreamer
    .search(searchCriteria)
    .pipe(operators_1.toArray())
    .toPromise();
  // 2. Exclude invalid transactions
  const results = data.filter((transaction) => {
    var _a;
    const transactionInfo = transaction.transactionInfo;
    if (!transactionInfo) return false;
    const transactionIndex = transactionInfo.index;
    const transactionHash =
      transactionInfo instanceof symbol_sdk_1.AggregateTransactionInfo
        ? transactionInfo.aggregateHash
        : (_a = transactionInfo.hash) !== null && _a !== void 0
        ? _a
        : '';
    return (
      // 2.a
      dbService.existsUser(transaction.message.payload) &&
      // 2.b
      transaction.mosaics.length === 1 &&
      (transaction.mosaics[0].id.toHex() === config.tokenId.toHex() ||
        transaction.mosaics[0].id.toHex() === config.tokenAlias.toHex()) &&
      // 2.c
      !dbService.existsTransaction(transactionHash, transactionIndex)
    );
  });
  // 3. Record the valid deposits in the exchange database
  results.forEach((transaction) => {
    var _a;
    const transactionInfo = transaction.transactionInfo;
    if (!transactionInfo) return;
    const transactionHash =
      transactionInfo instanceof symbol_sdk_1.AggregateTransactionInfo
        ? transactionInfo.aggregateHash
        : (_a = transactionInfo.hash) !== null && _a !== void 0
        ? _a
        : '';
    const transactionIndex = transactionInfo.index;
    const amount =
      transaction.mosaics[0].amount.compact() /
      Math.pow(10, config.tokenDivisibility);
    dbService.recordDeposit(
      transaction.message.payload,
      amount,
      transactionHash,
      transactionIndex,
    );
  });
  // 4. Store the last height that has been processed
  dbService.setLastProcessedHeight(maxHeight);

All configuration data is held in the ExchangeSymbolConfig object including the selection of the finalization mechanism.

The above code snippet should be called in a loop every minute, for example, and it will process all new valid transactions that have already been finalized (or that have waited enough blocks, depending on the chosen method).

However, transactions will not be reported immediately, and this might be annoying for users. Using WebSockets transactions can be monitored in real-time and a notification can be shown to the user as soon as a transaction is confirmed on the network (or even as soon as it is announced on the network).

These transactions, though, should be clearly marked as pending and not acted upon until verified by the above code, to avoid rollbacks.

Withdrawals

../../_images/exchange-integration-withdrawal.png

Fig. 3: Withdrawal process.

Users send withdrawal requests to the Exchange Server, via a web page or mobile app, for example. If the database indicates that the user has enough funds to perform the withdrawal, a transfer transaction is announced from the Exchange Central Wallet to the Symbol address indicated in the request.

Announcing the transaction has a fee, which is paid by the Exchange Central Wallet but can be deduced from the user’s account. Regardless of the token being transferred, fees are always paid in XYM tokens.

The withdrawal process requires two steps: First the transaction transferring the funds is announced and confirmed (added to the blockchain). Afterwards, the exchange needs to wait for the transaction to be finalized, as explained in the Avoiding rollbacks section above.

Announcing

The withdrawal transaction is just a regular Symbol transfer transaction. The code looks long because it contains a lot of repeated boilerplate, to make it self-contained:

  • Configuration is stored in the ExchangeSymbolConfig object.

  • A number of repositories are instantiated via the RepositoryFactoryHttp class.

  • The withdrawal details are retrieved from environment variables in this example.

Then:

  1. The actual transaction is created using TransferTransaction.create.

  2. The transaction is signed.

  3. The signed transaction is announced using the TransactionService to simplify waiting for its confirmation.

  // Mocks a database service
  const dbService = new DBServiceImpl();

  // Exchange configuration
  const config: ExchangeSymbolConfig = {
    // Replace with your node URL
    apiUrl: 'NODE_URL',
    // Use MAIN_NET or TEST_NET
    networkType: NetworkType.TEST_NET,
    // Replace with value from http://<API-NODE-URL>:3000/network/properties
    networkGenerationHashSeed:
      '3B5E1FA6445653C971A50687E75E6D09FB30481055E3990C84B25E9222DC1155',
    // Replace with the account central address
    centralAccountAddress: Address.createFromRawAddress(
      'TAOXUJOTTW3W5XTBQMQEX3SQNA6MCUVGXLXR3TA',
    ),
    // Use 6BED913FA20223F8 for MAIN_NET or 091F837E059AE13C for TEST_NET
    tokenId: new MosaicId('091F837E059AE13C'),
    tokenAlias: new NamespaceId('symbol.xym'),
    tokenDivisibility: 6,
    requiredConfirmations: 20,
    useFinalization: true,
  };

  // Repositories
  const repositoryFactory = new RepositoryFactoryHttp(config.apiUrl);
  const listener = repositoryFactory.createListener();
  const transactionHttp = repositoryFactory.createTransactionRepository();
  const chainHttp = repositoryFactory.createChainRepository();
  const transactionService = new TransactionService(
    transactionHttp,
    repositoryFactory.createReceiptRepository(),
  );
  const transactionPaginationStreamer = new TransactionPaginationStreamer(
    transactionHttp,
  );

  // Replace with exchange account private key
  const exchangeAccountPrivateKey = process.env.PRIVATE_KEY as string;
  const exchangeAccount = Account.createFromPrivateKey(
    exchangeAccountPrivateKey,
    config.networkType,
  );
  // Replace with destination address from user's request
  const userRawAddress = process.env.RECIPIENT_ADDRESS as string;
  const userAddress = Address.createFromRawAddress(userRawAddress);
  // Replace with the source UUID from user's request
  const uuid = process.env.UUID as string;
  // Replace with amount of tokens to transfer from user's request
  const relativeAmount = parseFloat(process.env.AMOUNT as string);

  // Check that the user has enough funds
  if (dbService.getUserAmount(uuid, config.tokenId) < relativeAmount) {
    throw Error('User ' + uuid + ' does not have enough funds.');
  }

  // 1. Create withdrawal transaction
  const absoluteAmount =
    relativeAmount * Math.pow(10, config.tokenDivisibility);
  const token = new Mosaic(config.tokenId, UInt64.fromUint(absoluteAmount));

  const epochAdjustment = await repositoryFactory
    .getEpochAdjustment()
    .toPromise();

  const withdrawalTransaction = TransferTransaction.create(
    Deadline.create(epochAdjustment),
    userAddress,
    [token],
    EmptyMessage,
    config.networkType,
    UInt64.fromUint(200000), // Fixed max fee of 0.2 XYM
  );

  // 2. Sign transaction
  const signedTransaction = exchangeAccount.sign(
    withdrawalTransaction,
    config.networkGenerationHashSeed,
  );

  // 3. Announce transaction and wait for confirmation
  console.log('Announcing transaction', signedTransaction.hash);
  await listener.open();
  const transaction = await transactionService
    .announce(signedTransaction, listener)
    .toPromise();
  console.log(
    'Transaction confirmed at height',
    transaction.transactionInfo?.height.compact() ?? 0,
  );
  listener.close();
  // Mocks a database service
  const dbService = new DBServiceImpl_1.DBServiceImpl();
  // Exchange configuration
  const config = {
    // Replace with your node URL
    apiUrl: 'NODE_URL',
    // Use MAIN_NET or TEST_NET
    networkType: symbol_sdk_1.NetworkType.TEST_NET,
    // Replace with value from http://<API-NODE-URL>:3000/network/properties
    networkGenerationHashSeed:
      '3B5E1FA6445653C971A50687E75E6D09FB30481055E3990C84B25E9222DC1155',
    // Replace with the account central address
    centralAccountAddress: symbol_sdk_1.Address.createFromRawAddress(
      'TAOXUJOTTW3W5XTBQMQEX3SQNA6MCUVGXLXR3TA',
    ),
    // Use 6BED913FA20223F8 for MAIN_NET or 091F837E059AE13C for TEST_NET
    tokenId: new symbol_sdk_1.MosaicId('091F837E059AE13C'),
    tokenAlias: new symbol_sdk_1.NamespaceId('symbol.xym'),
    tokenDivisibility: 6,
    requiredConfirmations: 20,
    useFinalization: true,
  };
  // Repositories
  const repositoryFactory = new symbol_sdk_1.RepositoryFactoryHttp(
    config.apiUrl,
  );
  const listener = repositoryFactory.createListener();
  const transactionHttp = repositoryFactory.createTransactionRepository();
  const chainHttp = repositoryFactory.createChainRepository();
  const transactionService = new symbol_sdk_1.TransactionService(
    transactionHttp,
    repositoryFactory.createReceiptRepository(),
  );
  const transactionPaginationStreamer = new symbol_sdk_1.TransactionPaginationStreamer(
    transactionHttp,
  );
  // Replace with exchange account private key
  const exchangeAccountPrivateKey = process.env.PRIVATE_KEY;
  const exchangeAccount = symbol_sdk_1.Account.createFromPrivateKey(
    exchangeAccountPrivateKey,
    config.networkType,
  );
  // Replace with destination address from user's request
  const userRawAddress = process.env.RECIPIENT_ADDRESS;
  const userAddress = symbol_sdk_1.Address.createFromRawAddress(userRawAddress);
  // Replace with the source UUID from user's request
  const uuid = process.env.UUID;
  // Replace with amount of tokens to transfer from user's request
  const relativeAmount = parseFloat(process.env.AMOUNT);
  // Check that the user has enough funds
  if (dbService.getUserAmount(uuid, config.tokenId) < relativeAmount) {
    throw Error('User ' + uuid + ' does not have enough funds.');
  }
  // 1. Create withdrawal transaction
  const absoluteAmount =
    relativeAmount * Math.pow(10, config.tokenDivisibility);
  const token = new symbol_sdk_1.Mosaic(
    config.tokenId,
    symbol_sdk_1.UInt64.fromUint(absoluteAmount),
  );
  const epochAdjustment = await repositoryFactory
    .getEpochAdjustment()
    .toPromise();
  const withdrawalTransaction = symbol_sdk_1.TransferTransaction.create(
    symbol_sdk_1.Deadline.create(epochAdjustment),
    userAddress,
    [token],
    symbol_sdk_1.EmptyMessage,
    config.networkType,
    symbol_sdk_1.UInt64.fromUint(200000),
  );
  // 2. Sign transaction
  const signedTransaction = exchangeAccount.sign(
    withdrawalTransaction,
    config.networkGenerationHashSeed,
  );
  // 3. Announce transaction and wait for confirmation
  console.log('Announcing transaction', signedTransaction.hash);
  await listener.open();
  const transaction = await transactionService
    .announce(signedTransaction, listener)
    .toPromise();
  console.log(
    'Transaction confirmed at height',
    (_b =
      (_a = transaction.transactionInfo) === null || _a === void 0
        ? void 0
        : _a.height.compact()) !== null && _b !== void 0
      ? _b
      : 0,
  );
  listener.close();

Multi-signature accounts

When the Exchange Central Wallet is a multi-signature account announcing the transaction is slightly more complex, as it involves the central wallet and its cosignatories. See the following resources:

Once the transaction is confirmed, the next step is to wait for it to be finalized to make sure it cannot be reverted. Until then, it should be marked as pending and not acted upon.

Finalization

Waiting for finalization is performed in a manner very similar to how incoming deposits are monitored (see Deposits above): The blockchain is polled periodically and all transactions since the last check are processed in a batch, looking for outgoing transfers which have already been finalized.

The following code snippet should be run in a loop every minute, for example, and it will search for finalized withdrawal operations from the Exchange and record them in the Exchange’s database.

This snippet can be run in the same loop as the deposits monitor described above.

    // Get current chain height and latest finalized block
    const {
      height: currentHeight,
      latestFinalizedBlock: finalizationBlock,
    } = await chainHttp.getChainInfo().toPromise();
    const maxHeight = config.useFinalization
      ? finalizationBlock.height
      : currentHeight.subtract(UInt64.fromUint(config.requiredConfirmations));

    // Bail out if there have been no new (final) transactions since last check
    const lastProcessedHeight = dbService.getLastProcessedHeight();
    if (lastProcessedHeight.equals(maxHeight)) return;

    // Look for confirmed transactions signed by the central account address,
    // in the desired block height range.
    const searchCriteria = {
      group: TransactionGroup.Confirmed,
      signerPublicKey: exchangeAccount.publicKey,
      embedded: true,
      order: Order.Asc,
      type: [TransactionType.TRANSFER],
      fromHeight: lastProcessedHeight.add(UInt64.fromUint(1)),
      toHeight: maxHeight,
    } as TransactionSearchCriteria;

    const data = await transactionPaginationStreamer
      .search(searchCriteria)
      .pipe(toArray() as any)
      .toPromise();

    console.log(
      'Processing',
      (data as TransferTransaction[]).length,
      'entries from height',
      searchCriteria.fromHeight?.compact(),
      'to',
      searchCriteria.toHeight?.compact(),
    );

    // Record the valid withdrawals in the exchange database
    (data as TransferTransaction[]).forEach((transaction) => {
      const transactionInfo = transaction.transactionInfo;
      if (!transactionInfo) return;
      const transactionHash =
        transactionInfo instanceof AggregateTransactionInfo
          ? transactionInfo.aggregateHash
          : transactionInfo.hash ?? '';
      const amount =
        transaction.mosaics[0].amount.compact() /
        Math.pow(10, config.tokenDivisibility);
      dbService.recordWithdrawal(uuid, amount, transactionHash);
    });

    // Store the last height that has been processed
    dbService.setLastProcessedHeight(maxHeight);
    // Get current chain height and latest finalized block
    const {
      height: currentHeight,
      latestFinalizedBlock: finalizationBlock,
    } = await chainHttp.getChainInfo().toPromise();
    const maxHeight = config.useFinalization
      ? finalizationBlock.height
      : currentHeight.subtract(
          symbol_sdk_1.UInt64.fromUint(config.requiredConfirmations),
        );
    // Bail out if there have been no new (final) transactions since last check
    const lastProcessedHeight = dbService.getLastProcessedHeight();
    if (lastProcessedHeight.equals(maxHeight)) return;
    // Look for confirmed transactions signed by the central account address,
    // in the desired block height range.
    const searchCriteria = {
      group: symbol_sdk_1.TransactionGroup.Confirmed,
      signerPublicKey: exchangeAccount.publicKey,
      embedded: true,
      order: symbol_sdk_1.Order.Asc,
      type: [symbol_sdk_1.TransactionType.TRANSFER],
      fromHeight: lastProcessedHeight.add(symbol_sdk_1.UInt64.fromUint(1)),
      toHeight: maxHeight,
    };
    const data = await transactionPaginationStreamer
      .search(searchCriteria)
      .pipe(operators_1.toArray())
      .toPromise();
    console.log(
      'Processing',
      data.length,
      'entries from height',
      (_a = searchCriteria.fromHeight) === null || _a === void 0
        ? void 0
        : _a.compact(),
      'to',
      (_b = searchCriteria.toHeight) === null || _b === void 0
        ? void 0
        : _b.compact(),
    );
    // Record the valid withdrawals in the exchange database
    data.forEach((transaction) => {
      var _a;
      const transactionInfo = transaction.transactionInfo;
      if (!transactionInfo) return;
      const transactionHash =
        transactionInfo instanceof symbol_sdk_1.AggregateTransactionInfo
          ? transactionInfo.aggregateHash
          : (_a = transactionInfo.hash) !== null && _a !== void 0
          ? _a
          : '';
      const amount =
        transaction.mosaics[0].amount.compact() /
        Math.pow(10, config.tokenDivisibility);
      dbService.recordWithdrawal(uuid, amount, transactionHash);
    });
    // Store the last height that has been processed
    dbService.setLastProcessedHeight(maxHeight);

Further information

Read the following pages for more information: