1 Exchange Integration
Claude edited this page 2026-04-03 11:26:56 +01:00
This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Exchange Integration Guide

This guide covers everything exchanges and services need to integrate Lethean, including RPC setup, integrated addresses, production node configuration, gateway addresses, JWT authentication, multi-asset custody, offline signing, and frequently asked questions.

Starting the Daemon and Wallet as RPC Server

The Lethean wallet CLI (lethean-wallet-cli / lethean-testnet-wallet-cli) can run as an RPC server, controlled via HTTP JSON-RPC calls. This is the standard setup for exchanges, payment processors, and automated services.

1. Start the daemon

## Mainnet
./lethean-chain-node --data-dir ./chain-data

## Testnet
./lethean-testnet-chain-node --data-dir ./testnet-data

2. Create a wallet (first time only)

./lethean-wallet-cli --generate-new-wallet /path/to/exchange-wallet \
  --password "STRONG_PASSWORD" \
  --daemon-address 127.0.0.1:36941

Save the 24-word seed phrase securely. The wallet address starts with iTHN.

3. Start wallet in RPC mode

./lethean-wallet-cli \
  --wallet-file /path/to/exchange-wallet \
  --password "STRONG_PASSWORD" \
  --rpc-bind-ip 127.0.0.1 \
  --rpc-bind-port 36944 \
  --daemon-address 127.0.0.1:36941 \
  --log-file wallet-rpc.log

4. Test the connection

## Get wallet address
curl -X POST http://127.0.0.1:36944/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"getaddress"}'

## Get balance
curl -X POST http://127.0.0.1:36944/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"getbalance"}'

Port reference

Service Mainnet Testnet
Daemon RPC 36941 46941
Daemon P2P 36942 46942
Wallet RPC 36944 (custom) 46944 (custom)

Units

All amounts and balances are unsigned integers in atomic units. One LTHN = 10^12 atomic units.

Human Atomic
1 LTHN 1000000000000
0.5 LTHN 500000000000
0.01 LTHN (min fee) 10000000000

Verified against testnet

These commands have been tested against the live Lethean testnet at height 10,676. The wallet RPC responds to getaddress, getbalance, get_wallet_info, make_integrated_address, and get_bare_outs_stats.


Integrated addresses for exchanges

Starting the daemon and the wallet application as RPC server

Unlike Bitcoin, CryptoNote family coins have different, more effective approach on how to handle user deposits.

An exchange generates only one address for receiving coins and all users send coins to that address. To distinguish different deposits from different users the exchange generates random identifier (called payment ID) for each one and a user attaches this payment ID to his transaction while sending. Upon receiving, the exchange can extract payment ID and thus identify the user.
In original CryptoNote there were two separate things: exchange deposit address (the same for all users) and payment ID (unique for all users). Later, for user convenience and to avoid missing payment ID we combined them together into one thing, called integrated address. So nowadays modern exchanges usually give to a user an integrated address for depositing instead of pair of standard deposit address and a payment ID.

Creating an integrated address

## Generate with random payment ID
curl -X POST http://127.0.0.1:36944/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"make_integrated_address","params":{}}'

## Generate with specific payment ID (8 bytes hex)
curl -X POST http://127.0.0.1:36944/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"make_integrated_address","params":{"payment_id":"cafebabe12345678"}}'

Response:

{
  "result": {
    "integrated_address": "iTHnNj4dWPHP1yy8xH2y5iQa...",
    "payment_id": "cafebabe12345678"
  }
}

Note: Standard addresses start with iTHN (uppercase N), integrated addresses start with iTHn (lowercase n).

Splitting an integrated address

curl -X POST http://127.0.0.1:36944/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"split_integrated_address","params":{"integrated_address":"iTHnNj4dWPHP1yy8xH2y5iQa..."}}'

Detecting deposits

Use get_bulk_payments to poll for incoming payments by payment ID:

curl -X POST http://127.0.0.1:36944/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"get_bulk_payments","params":{"payment_ids":["cafebabe12345678"],"min_block_height":10000}}'

For more details, see:

Verified: Integrated address generation tested on Lethean testnet. The iTHn prefix was confirmed for integrated addresses vs iTHN for standard addresses.


Hi-Load Servers (WARP-mode)

To improve the efficiency of production servers under heavy loads—such as remote nodes for mobile wallets—we have implemented a special daemon mode called WARP-mode(derived from “full warm up”). This mode requires at least 64GB of physical RAM, but it can deliver up to a x20 performance boost compared to the standard mode.

To use WARP-mode, make sure your server has at least 64 GB of physical RAM.

To enable WARP-mode launch daemon with this command line option:

lethean-chain-node --do-warp-mode

2025-Mar-13 13:51:18.787912 Lethean v2.1.0.382[926549e]
2025-Mar-13 13:51:18.793423 Starting...
2025-Mar-13 13:51:18.794923 Module folder: C:\Users\roky\home\projects\lethean_UI_x64\build\src\Release\lethean-chain-node.exe
2025-Mar-13 13:51:18.806597 Pre-downloading not needed (db file size = 12569350144)
2025-Mar-13 13:51:18.808596 Initializing p2p server...
......
2025-Mar-13 13:51:19.042870 Core initialized OK
2025-Mar-13 13:51:19.044871 Initializing full warp-mode
2025-Mar-13 13:51:19.045871 Using db items cache size(L2): 38.91 M items
2025-Mar-13 13:51:19.049737 [Warp]: Setting up db cache to 38.91 M items.....
2025-Mar-13 13:51:19.051743 [Warp]: Detected only 53.99 GB RAM, might be not optimal, recommended above 64.00 GB
2025-Mar-13 13:51:19.053738 [Warp]: Launching warm up....
2025-Mar-13 13:51:19.055737 Warming up starting, total blocks...3064647
2025-Mar-13 13:51:19.059450 Warming up: 0%, 1 of 3064647
2025-Mar-13 13:51:52.573744 Warming up: 0%, 28812 of 3064647
2025-Mar-13 13:51:54.604781 Warming up: 1%, 30979 of 3064647
2025-Mar-13 13:52:19.995541 Warming up: 1%, 61177 of 3064647
.....
2025-Mar-13 13:52:19.995541 [Warp]: Warm up finished!

This command line option reconfigure core cache paprameters and then "warm up" all data stored in databse into cache(which might take some time - up to 10 minutes), and after that the server would start handling network reaquests(RPC and P2P).

Technical Note

From a technical standpoint, the getblocks.bin RPC request essential for wallet synchronization and one of the most time-consuming operations must load each of roughly 4,000 blocks from the database and then deserialize them into C++ structures. The same process is repeated for every transaction within each block. To alleviate this load, Lethean introduced a special caching mechanism about seven years ago. Each database table has its own cache, which by default can hold around 10,000 items. This lets the daemon quickly carry out tasks needed for fast block validation (for example, calculating various block medians or recalculating the next difficulty).

To further enhance performance in production, we decided to fully leverage this cache by setting its size larger than the entire storage. This ensures that all blocks and transactions remain “hot” loaded and deserialized at all times. Currently, with over three million blocks, a fully loaded database is about 35GB. We expect this size to grow, though wallet requests typically focus on more recent blocks. Even if, in the future, the loaded database exceeds the amount of physical RAM, WARP-mode will still be significantly more efficient because any swapping is efficiently handled at the operating system level, and there is no additional deserialization phase.


Gateway Addresses (GW) in Lethean

1. What are Gateway Addresses?

A Gateway Address (GW) is a new type of address in the Lethean blockchain that will be introduced starting with Hard Fork 6. Unlike classic addresses, which operate under the UTXO model, GW addresses use an account-based model, meaning that the balance is stored directly on the blockchain rather than being represented as a set of separate UTXOs.

GW addresses are designed to simplify integration with external systems such as cross-chain bridges, DEXs, exchanges, and payment gateways. They follow an approach that is more familiar to these services and provide a simple API that is, in some respects, closer to those used by more traditional blockchain platforms such as Bitcoin and Ethereum. This makes it easier for such services to use established custody frameworks, including MPC, and helps make the integration process more transparent and reliable.

In addition, because GW addresses use an account-based model, they avoid the issues related to UTXO fragmentation and distribution that are common in traditional UTXO-based blockchains.

2. Structure

Gateway Addresses are entities that must first be registered on the blockchain before they can be used. A GW address can only be registered through a standard Lethean wallet by calling the Wallet RPC API(see doc below). Once a GW address is registered, it is assigned an ID, which is effectively its public view key, and presented to user as a string starting from gwZ... (regular) or gwiZ...(integrated). After that, coins can be sent to this GW address.

Each Gateway Address is associated with two private keys controlled by its owner: a view key and a spend key(owner key). In the simplest case, the view key and the spend key may be the same.

In a more advanced setup, a GW address may use different view and spend keys. Importantly, the spend key can be replaced by the owner of the GW address.

Keys associated with GW address

Each GW address is associated with two public keys: a view key and a spend key.

The view key is used exclusively to derive a shared secret between the sender and the recipient, and to encrypt additional information attached to the transaction, such as comments or a payment_id, using that secret. Once a view key has been associated with a GW address, it cannot be changed.

The spend key acts as a master key that controls all operations related to a GW address, including spending funds and assigning a new owner by replacing the spend key. From a security perspective, this is the most critical key, as it ultimately controls all funds associated with the address.

To make integration with Lethean convenient for a wide range of services, we implemented support for several signature types that are widely used across the blockchain industry. Below is a description of these signature types, along with the names of the corresponding API fields in the Wallet RPC API register_gateway_address:

Name in API Curve Public key Signature Use case
opt_owner_ecdsa_pub_key secp256k1 33 bytes (compressed) 64 bytes
(r || s)
ECDSA over secp256k1. This signature type is widely used in blockchain projects such as Ethereum, Bitcoin, and others.
opt_owner_eddsa_pub_key Ed25519 32 bytes 64 bytes
(R || s)
EDDSA (also referred to as EdDSA). This is the variant used in Solana.
opt_owner_custom_schnorr_pub_key Ed25519 32 bytes 64 bytes
(c || y)
Lethean custom Schnorr signature, also based on Ed25519.

opt_owner_ecdsa_pub_key(ECDSA) and opt_owner_eddsa_pub_key(Ed25519) were implemented primarily because these standards are widely supported across the blockchain industry and because there is extensive tooling available for building MPC solutions with these key types.

opt_owner_custom_schnorr_pub_key(Lethean custom Schnorr signature) is an internal algorithm native to the Lethean codebase and integrated into Letheans core transaction protocols. It has similarities to the scheme used in Solana and relies on the same elliptic curve, but for historical reasons it differs in several implementation details, including the hash function used in the Schnorr algorithm.

Note: View key (view_pub_key) can be only Lethean custom Schnorr signature, as it involved in internal protocol machinery. Only spend key could be assigned as ECDSA/EDDSA*

All three types use the compact signature format (64 bytes, without the recovery byte v). The signature is transmitted as a hex string (128 characters).

Privacy

When a transaction is sent to or from a GW address, some confidentiality is intentionally sacrificed for the parts of the transaction that directly involve the GW address, whether as the sender or the recipient:

  • Amounts transferred to or from a GW address are stored in an open form. Anyone can see how much was sent or received by the GW address, while the counterparty remains hidden through commitments and stealth addresses.
  • The GW address itself is visible in each input or output associated with it, whereas regular addresses remain hidden.
  • The Payment ID always remains encrypted using key derivation from the view key. Decryption requires the view_secret_key.

This design makes GW addresses suitable for use cases such as bridges, exchanges, and similar services, where a certain level of transparency is acceptable or required.


3. Creating a GW address

Registration of a GW address is done via wallet RPC (register_gateway_address), a Lethean wallet with sufficient balance is required.

Prerequisites

IMPORTANT: You must use YOUR OWN NODE, as the view key will be transferred there.

  • Running and synced Lethean daemon (lethean-chain-node) on a network with hard fork 6+
  • A wallet with RPC server enabled (HOWTO)
  • Balance of at least ~100.01 LTHN (100 LTHN - Registration fee + Default fee)

Steps

Step 1: Key generation

Generate two key pairs:

  1. View keys (view_pub_key, view_secret_key) - the GW address identifier. view_pub_key will become the address (gwZ...), and view_secret_key will be needed later to decrypt payment ID in transaction history
  2. Owner keys (owner_pub_key, owner_secret_key) - the owners key for signing transactions.

Note: In the simplest setup, the same key can be used both as the view key and as the ownership key by providing it as both view_pub_key and opt_owner_custom_schnorr_pub_key in register_gateway_address.
However, to provide more complete guidance, the examples below use a more advanced configuration in which the owner and view keys would be different.

Note on view key generating: L and main subgroup

The GW address view key is a point on the Ed25519 curve. The Ed25519 curve has order 8 * L, where L is the order of the prime-order subgroup:

Wiki Curve25519 L magic number

L = 2^252 + 27742317777372353535851937790883648493 

If the secret scalar is chosen arbitrarily (without the restriction < L), the resulting point may contain a torsion component — a small multiplier of order 2, 4, or 8. Such points lie outside the main subgroup and create a vulnerability: two different scalars can generate the same point (address collision).

During registration, Lethean Core verifies that view_pub_key belongs to the main L-subgroup (no torsion). Therefore, when generating a view key, you need to:

  1. Select a random scalar s in the range [1, L-1]
  2. Compute the public key as s * G

This ensures that the public key resides in the main subgroup and that the registration will pass validation.

ECDSA low-S normalisation

EIP-2 explaining link

For ECDSA signatures (secp256k1), Lethean requires that the value of s be in the "lower half" (s <= n/2, where n is the order of the secp256k1 curve). This is a standard requirement (EIP-2) that prevents signature malleability. Our ethers.js v6 library automatically normalizes s when calling signingKey.sign(), so no additional action is required.

nodejs - keygen example

// gen Ed25519 view keys (valid scalar < L, main-subgroup public key)
const ED25519_L = (1n << 252n) + 27742317777372353535851937790883648493n;

function randomScalarLtL() {
  while (true) {
    const b = ethers.randomBytes(32);
    const x = BigInt('0x' + Buffer.from(b).toString('hex'));
    const s = x % ED25519_L;
    if (s !== 0n) return s;
  }
}

function scalarToLe32Hex(s) {
  const out = Buffer.alloc(32, 0);
  let x = s;
  for (let i = 0; i < 32; i++) {
    out[i] = Number(x & 0xffn);
    x >>= 8n;
  }
  return out.toString('hex');
}

const scalar = randomScalarLtL();
const viewSecretKey = scalarToLe32Hex(scalar);
const viewPubKey = Buffer.from(ed.Point.BASE.multiply(scalar).toBytes()).toString('hex');

console.log('View public key (GW address ID):', viewPubKey);
console.log('View secret key (save securely!!):', viewSecretKey);

// gen Ethereum owner keys for tx sign
const ownerWallet = ethers.Wallet.createRandom();
const ownerEthPubKey = ethers.SigningKey.computePublicKey(ownerWallet.privateKey, true).substring(2); // remove '0x', 33 bytes hex

console.log('Owner ETH public key:', ownerEthPubKey);
console.log('Owner ETH private key (save securely!):', ownerWallet.privateKey);

Step 2: Call register_gateway_address

User                  Wallet RPC            Blockchain
     |                     |                    |
     | 1. Generate keys    |                    |
     |    view + owner     |                    |
     |                     |                    |
     | register_gateway_   |                    |
     | address ----------> |                    |
     |                     | TX (fee=100 LTHN)  |
     |                     | -----------------> |
     |                     |                    | Validation:
     |                     |                    | - fee >= 100 LTHN
     |                     |                    | - no torsion
     |                     |                    | - address not taken
     |                     |                    |
     | gwZ..., tx_id  <--- |                    |
     |                     |                    |

Request (wallet JSON-RPC):

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "register_gateway_address",
  "params": {
    "view_pub_key": "4dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef180",
    "descriptor_info": {
      "opt_owner_ecdsa_pub_key": "0375d5e222b50cb55ede0a70ebb398ebc9e5d5e74ea0cbce860d4a38301877f4f7",
      "meta_info": "Example GW address for documentation"
    }
  }
}

Instead of opt_owner_ecdsa_pub_key you can specify:

  • opt_owner_custom_schnorr_pub_key - for Schnorr key (64 hex)
  • opt_owner_eddsa_pub_key - for EdDSA key (64 hex)

Exactly one owner key type must be specified.

Response:

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "address": "gwZ5sqxb53vdd7RjUoPoSWeELimisUGprEX3P8fhcQwc8Dw6xpnSgfC1v",
    "address_id": "4dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef180",
    "status": "OK",
    "tx_id": "3a0af1fc8dd142234e0ee300ccd930964d1a17ff9bab51cf610818db0e82fc4a"
  }
}

After the transaction is confirmed in a block, the GW address is registered permanently.

Node.js - registration and verification:

async function registerGatewayAddress(viewPubKey, ownerEthPubKey) {
  const regResult = await callWalletRpc('register_gateway_address', {
    view_pub_key: viewPubKey,
    descriptor_info: {
      opt_owner_ecdsa_pub_key: ownerEthPubKey,
      meta_info: 'Example GW address for documentation',
    },
  });

  console.log('Registered GW address:', regResult.address);
  console.log('Transaction ID:', regResult.tx_id);

  console.log('Waiting for confirmation...');
  await sleep(20000);

  const info = await callDaemonRpc('gateway_get_address_info', {
    gateway_address: regResult.address,
  });

  console.log('GW address info:', JSON.stringify(info, null, 2));
  console.log('Balances:', info.balances);
  console.log('View pub key:', info.gateway_view_pub_key);

  return regResult.address;
}

Step 3: Verify registration

Use daemon RPC gateway_get_address_info:

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "gateway_get_address_info",
  "params": {
    "gateway_address": "gwZ5sqxb53vdd7RjUoPoSWeELimisUGprEX3P8fhcQwc8Dw6xpnSgfC1v"
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "status": "OK",
    "descriptor_info": {
      "opt_owner_ecdsa_pub_key": "0375d5e222b50cb55ede0a70ebb398ebc9e5d5e74ea0cbce860d4a38301877f4f7",
      "meta_info": "Example GW address for documentation"
    },
    "balances": [],
    "gateway_view_pub_key": "4dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef180",
    "payment_id": ""
  }
}

Balances are still empty - the address was just created


4. Sending funds from a GW address (two-step API)

Sending funds from a GW address is done via daemon RPC (!not wallet RPC!) in three stages: creating an unsigned transaction, signing, and broadcasting to the network.

This approach allows signing transactions outside the daemon - for example, in MPC framework, on a cold wallet, or in any external system.

General flow

External service            Daemon (lethean-chain-node)            Blockchain
      |                           |                       |
      |  gateway_create_          |                       |
      |  transfer --------------> |                       |
      |    origin_gateway_id      | Checks balance        |
      |    destinations, fee      | Builds unsigned TX    |
      |                           |                       |
      |  tx_hash_to_sign     <--- |                       |
      |  tx_blob                  |                       |
      |                           |                       |
      |  +--------------------+   |                       |
      |  | Signs               |  |                       |
      |  | tx_hash_to_sign     |  |                       |
      |  | with owner_key      |  |                       |
      |  | (offline/MetaMask)  |  |                       |
      |  +--------------------+   |                       |
      |                           |                       |
      |  gateway_sign_            |                       |
      |  transfer --------------> |                       |
      |    tx_blob + signature    | Inserts signature     |
      |                           |                       |
      |  signed_tx_blob      <--- |                       |
      |                           |                       |
      |  sendrawtransaction ----> | --------------------> |
      |                           |        Into block     |

Step 1: Create transaction - gateway_create_transfer

Request (daemon JSON-RPC):

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "gateway_create_transfer",
  "params": {
    "origin_gateway_id": "4dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef180",
    "destinations": [
      {
        "amount": 1000000000000,
        "address": "iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY",
        "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a"
      }
    ],
    "fee": 100000000,
    "comment": "Payment for services"
  }
}

Destinations can be both regular addresses (Z...) and other GW addresses (gwZ...).

Response:

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "status": "OK",
    "tx_hash_to_sign": "20e922b32dfe9b8b6bc6004e40f4198c9e966d5e228cd4830656ba967f8a205c",
    "tx_blob": "040141004dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef18074c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b626880988be49903000516787ebe13ed53f6e5225ef5c481024b7f331a42fefa6d859c785521116659150a1700000b0217ce0b0264e42700e40b5402000000023f00f2085ea66732a56db0771aefcaa9c9fcb7828bcf4275d45579c5921d13516df041eacba1733953ed9dd2d2672d20c866076477c32e061536deb1245cb2804137e64ba3acd47465143f7e568afc2c1c3add2b9ffc13520feec9cb8ee734dbd3a574c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268d2ff9f07847d62ebc7202bc521e74f94003f007e5ad2eab4fb7f4a0b19177b593043e30640993e7c844397767c8a22e91521bb97c2d275ee1760109f93d2021cfebffd73aa1e0da75a3cbd737910de3fbe92fb4bbb7523374f82711e894d357b82283f5df7963769d77a80572c0612b0be151374c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268e359da40dc207efd55f19663586410d00006000143004600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000032e002f07b6039875cc562b68a034d3acd220672f7213dce03a425deba13c9a01759d65b1c4e06210da36d87f7c6a81c46cd43ec869018e0b2497f6adf79f06aaad6a3080793811d4059534b25449addbcaa1ac642cb9086dbf060d0a8cd685d05532ef23c7e85c9eac2533d21571249799722f491f4318c1426b2ee61cb9575e824302bb95a145a2cb71a682fb652d431f8f17dd66e0036decc89a17784a5d84f4000e36580e5e0bd1bca7d3d4575c2ecec56afdb85ab06986bb35613262fd12e8080482d68d09e8e9b43779f6f9913977935ce00dc2b47e8121ab24d17cf8999992b0c2075effe6cd4b0ad21ac3a310a94cc5b481373c05960cb1ae134d75f8184005e45fc1134f2bd4de031826856e494d4d06e4b2d3164e98b752b4f559df5169b986378c0fd4ddbada717875a15097773974fec4a8419dcdad4fdceb7d6fa5ad71072f36a5888dedc29c3beabcc0784c1035cde704d14d799ee794558b1bfb839537a6663da725062aaa83bee9896eceffaf1236799bb59c7b9bc98c4c20414f254fd151438a6f4208e02bfa6050414dfb1df3c2e41d1173ee9c1da2b60e1fbf84519bad7690be9d01c927da4aa3ee59a7b292caafba860fe1687c344994e80f6b18f5853acfac90be73993e4658de57d6e15b242c8774816433e3a13c1e385bb649efe87e18a75624bd47b25e8414a6b7c2a2deea6988576d8ddeae0be1f689bfe7679f77afe745cf1d819b0d18783aef1122fa1a22917b95a0495b965aad389907aaf96dd1744f4ad5d615e5f46c0853ac8f5a43d404e576db9112eb10182a55f8047b96f8bdb3d0c7b04a2fcef6faa4b732b74d142158bbaba42f32a4216e22a60f33a99867067d22fae284cbefd0e77116ab5274d6fd904725cb23736c4a6d020102932a7296485c475d663f3da145150687fd97f229cb875b0723fad6e9ff545cf90dfa73f05cfb4b90a784d3baea4314a9fc99bb0be5e1eb9bf14341fcb3178a5102fdf319fbafb71264060a697b4b312111ebe4eee25afb2c6c3e12bdda4ed7ea0e433d03150eae875856e1ccba6d70d804747b22b7961bd2a332364919d0d5380002e2f5cac81634c1d768b2a61bb39fda2016fb0bdb75dec9277baa70d4ade8b9069eb890a3c331fd1e45f16fb76adaeaa090a8aa3af3691c0a4c6127ae4213dd0ac55f3f8d223f7f7ee7151e12d4ba8367b2c2bc299b5d30fa8056e7a95c1eb40c30e72c83b96c57d3c13f804e58c20571ef9c934860cbf66746a780e9dfa2444f0221bb69bc9c2099d3672bb30d95b4d1299c57d1b1ad077ea53b9887fc401cf70ab29cdb7bd636d417506749ab6aaae4d00395d8aaa4d5d82ec596c60d850db10e"
  }
}

The daemon checks:

  • The GW address exists in the blockchain
  • Sufficient funds for each asset_id (including fee in native coin)

Step 2: Sign transaction - gateway_sign_transfer

Take tx_hash_to_sign from the response and sign it with your owner_secret_key (the one whose public key was specified during registration). The signature can be generated in any external system.

Request (daemon JSON-RPC):

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "gateway_sign_transfer",
  "params": {
    "tx_blob": "040141004dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef18074c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b626880988be49903000516787ebe13ed53f6e5225ef5c481024b7f331a42fefa6d859c785521116659150a1700000b0217ce0b0264e42700e40b5402000000023f00f2085ea66732a56db0771aefcaa9c9fcb7828bcf4275d45579c5921d13516df041eacba1733953ed9dd2d2672d20c866076477c32e061536deb1245cb2804137e64ba3acd47465143f7e568afc2c1c3add2b9ffc13520feec9cb8ee734dbd3a574c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268d2ff9f07847d62ebc7202bc521e74f94003f007e5ad2eab4fb7f4a0b19177b593043e30640993e7c844397767c8a22e91521bb97c2d275ee1760109f93d2021cfebffd73aa1e0da75a3cbd737910de3fbe92fb4bbb7523374f82711e894d357b82283f5df7963769d77a80572c0612b0be151374c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268e359da40dc207efd55f19663586410d00006000143004600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000032e002f07b6039875cc562b68a034d3acd220672f7213dce03a425deba13c9a01759d65b1c4e06210da36d87f7c6a81c46cd43ec869018e0b2497f6adf79f06aaad6a3080793811d4059534b25449addbcaa1ac642cb9086dbf060d0a8cd685d05532ef23c7e85c9eac2533d21571249799722f491f4318c1426b2ee61cb9575e824302bb95a145a2cb71a682fb652d431f8f17dd66e0036decc89a17784a5d84f4000e36580e5e0bd1bca7d3d4575c2ecec56afdb85ab06986bb35613262fd12e8080482d68d09e8e9b43779f6f9913977935ce00dc2b47e8121ab24d17cf8999992b0c2075effe6cd4b0ad21ac3a310a94cc5b481373c05960cb1ae134d75f8184005e45fc1134f2bd4de031826856e494d4d06e4b2d3164e98b752b4f559df5169b986378c0fd4ddbada717875a15097773974fec4a8419dcdad4fdceb7d6fa5ad71072f36a5888dedc29c3beabcc0784c1035cde704d14d799ee794558b1bfb839537a6663da725062aaa83bee9896eceffaf1236799bb59c7b9bc98c4c20414f254fd151438a6f4208e02bfa6050414dfb1df3c2e41d1173ee9c1da2b60e1fbf84519bad7690be9d01c927da4aa3ee59a7b292caafba860fe1687c344994e80f6b18f5853acfac90be73993e4658de57d6e15b242c8774816433e3a13c1e385bb649efe87e18a75624bd47b25e8414a6b7c2a2deea6988576d8ddeae0be1f689bfe7679f77afe745cf1d819b0d18783aef1122fa1a22917b95a0495b965aad389907aaf96dd1744f4ad5d615e5f46c0853ac8f5a43d404e576db9112eb10182a55f8047b96f8bdb3d0c7b04a2fcef6faa4b732b74d142158bbaba42f32a4216e22a60f33a99867067d22fae284cbefd0e77116ab5274d6fd904725cb23736c4a6d020102932a7296485c475d663f3da145150687fd97f229cb875b0723fad6e9ff545cf90dfa73f05cfb4b90a784d3baea4314a9fc99bb0be5e1eb9bf14341fcb3178a5102fdf319fbafb71264060a697b4b312111ebe4eee25afb2c6c3e12bdda4ed7ea0e433d03150eae875856e1ccba6d70d804747b22b7961bd2a332364919d0d5380002e2f5cac81634c1d768b2a61bb39fda2016fb0bdb75dec9277baa70d4ade8b9069eb890a3c331fd1e45f16fb76adaeaa090a8aa3af3691c0a4c6127ae4213dd0ac55f3f8d223f7f7ee7151e12d4ba8367b2c2bc299b5d30fa8056e7a95c1eb40c30e72c83b96c57d3c13f804e58c20571ef9c934860cbf66746a780e9dfa2444f0221bb69bc9c2099d3672bb30d95b4d1299c57d1b1ad077ea53b9887fc401cf70ab29cdb7bd636d417506749ab6aaae4d00395d8aaa4d5d82ec596c60d850db10e",
    "tx_hash_to_sign": "20e922b32dfe9b8b6bc6004e40f4198c9e966d5e228cd4830656ba967f8a205c",
    "opt_ecdsa_signature": "c2b347b83da0a79637b74ad4b504030033b771ac8cb1f610757f82e88d112b1032ef615efb2cf3f2e70c15de9c63208ca80c1cea70f12785648c98a5ca3c7b40"
  }
}

Instead of opt_ecdsa_signature use:

  • opt_custom_schnorr_signature - if owner key is Schnorr
  • opt_eddsa_signature - if owner key is EdDSA

Exactly one signature must be specified.

Response:

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "status": "OK",
    "signed_tx_blob": "040141004dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef18074c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b626880988be49903000516787ebe13ed53f6e5225ef5c481024b7f331a42fefa6d859c785521116659150a1700000b0217ce0b0264e42700e40b5402000000023f00f2085ea66732a56db0771aefcaa9c9fcb7828bcf4275d45579c5921d13516df041eacba1733953ed9dd2d2672d20c866076477c32e061536deb1245cb2804137e64ba3acd47465143f7e568afc2c1c3add2b9ffc13520feec9cb8ee734dbd3a574c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268d2ff9f07847d62ebc7202bc521e74f94003f007e5ad2eab4fb7f4a0b19177b593043e30640993e7c844397767c8a22e91521bb97c2d275ee1760109f93d2021cfebffd73aa1e0da75a3cbd737910de3fbe92fb4bbb7523374f82711e894d357b82283f5df7963769d77a80572c0612b0be151374c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268e359da40dc207efd55f19663586410d0000600014300471cd5b4827d23f04b86a4adeb3733b414cae32221d82b6be30479f056030ae37c2d23ef2f4ad34c55f1a9e1c299a71ad6db414cd41d9e9202d0f3644acf6224f1032e002f07b6039875cc562b68a034d3acd220672f7213dce03a425deba13c9a01759d65b1c4e06210da36d87f7c6a81c46cd43ec869018e0b2497f6adf79f06aaad6a3080793811d4059534b25449addbcaa1ac642cb9086dbf060d0a8cd685d05532ef23c7e85c9eac2533d21571249799722f491f4318c1426b2ee61cb9575e824302bb95a145a2cb71a682fb652d431f8f17dd66e0036decc89a17784a5d84f4000e36580e5e0bd1bca7d3d4575c2ecec56afdb85ab06986bb35613262fd12e8080482d68d09e8e9b43779f6f9913977935ce00dc2b47e8121ab24d17cf8999992b0c2075effe6cd4b0ad21ac3a310a94cc5b481373c05960cb1ae134d75f8184005e45fc1134f2bd4de031826856e494d4d06e4b2d3164e98b752b4f559df5169b986378c0fd4ddbada717875a15097773974fec4a8419dcdad4fdceb7d6fa5ad71072f36a5888dedc29c3beabcc0784c1035cde704d14d799ee794558b1bfb839537a6663da725062aaa83bee9896eceffaf1236799bb59c7b9bc98c4c20414f254fd151438a6f4208e02bfa6050414dfb1df3c2e41d1173ee9c1da2b60e1fbf84519bad7690be9d01c927da4aa3ee59a7b292caafba860fe1687c344994e80f6b18f5853acfac90be73993e4658de57d6e15b242c8774816433e3a13c1e385bb649efe87e18a75624bd47b25e8414a6b7c2a2deea6988576d8ddeae0be1f689bfe7679f77afe745cf1d819b0d18783aef1122fa1a22917b95a0495b965aad389907aaf96dd1744f4ad5d615e5f46c0853ac8f5a43d404e576db9112eb10182a55f8047b96f8bdb3d0c7b04a2fcef6faa4b732b74d142158bbaba42f32a4216e22a60f33a99867067d22fae284cbefd0e77116ab5274d6fd904725cb23736c4a6d020102932a7296485c475d663f3da145150687fd97f229cb875b0723fad6e9ff545cf90dfa73f05cfb4b90a784d3baea4314a9fc99bb0be5e1eb9bf14341fcb3178a5102fdf319fbafb71264060a697b4b312111ebe4eee25afb2c6c3e12bdda4ed7ea0e433d03150eae875856e1ccba6d70d804747b22b7961bd2a332364919d0d5380002e2f5cac81634c1d768b2a61bb39fda2016fb0bdb75dec9277baa70d4ade8b9069eb890a3c331fd1e45f16fb76adaeaa090a8aa3af3691c0a4c6127ae4213dd0ac55f3f8d223f7f7ee7151e12d4ba8367b2c2bc299b5d30fa8056e7a95c1eb40c30e72c83b96c57d3c13f804e58c20571ef9c934860cbf66746a780e9dfa2444f0221bb69bc9c2099d3672bb30d95b4d1299c57d1b1ad077ea53b9887fc401cf70ab29cdb7bd636d417506749ab6aaae4d00395d8aaa4d5d82ec596c60d850db10e"
  }
}

Step 3: Broadcast transaction - sendrawtransaction

Request (daemon JSON-RPC):

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "sendrawtransaction",
  "params": {
    "tx_as_hex": "040141004dbaa579daf3c4a91e6be2efde9568975f7b506b50d18bbefd6f03132b2ef18074c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b626880988be49903000416bc24fb10b82024e49ab7182aa306f598fe1a25115709ac52bf8a5937823478f81700000b02414b2700e40b5402000000013f006a09e2c8d78239f6ee24e03e2fdeb1833d7db2a1054443c32437354507e77c9f6b112168f194242ec0272040f9ca3be77b1819dc979c0ed4f811806f9f41fffa070ba81bb0696493b30b622c72db50916ca84ed92cd31a24d9e12ec929e5717e74c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b62689694946034ed290c47fae35d7304c3cc000600014300473daee84aaa98d68c198d46b21d5c394c588909c06c68810c8cf49fbefa39eaaa0e8ad7b7da716c05806050459a7bef091406067c354f204a7f6e29572f8e756e032e002f0612fb23e26ee199be79821647fd5860b41a9fabfc5a10103832434b4f825ebd5735fc6801bac7aefa358989fe2085312e2d887bcdc8b4dc9d3fd98d8e8456cc7d5d2f1358e94b48ca2b77013a223532752f9e386d6fe2043bb9d1e8dc360f93c2b3a4c15b81caf3a5b3e9ce8a577dc2e621a645b83598ef1010011e13188a68805316fbeb7c5e02b98be11015395d612cee39947cffe6f1d9341da8c115c84ea80b338686aff8a82615e3676aab91a080cfed08375977aa1240aef82e6e2ccc5c06f9210c53cd37401b3321831b136f5301b1f8ef6f69f79d65c881707e7c47443371c3d6c7ee39a60453231d7e2fc0d3a9d8d88fee40a6672ba34a778116ecc29e10c7fedeada99c269503be5190411116a24d2b7a7180f52612b0e709d392c41139ef11e8b9c7fc13dfdb5628e6a7fcd10ab8a8627f076677d45a1d13e8083b05033b18ce459226204109f3f7dce14bba9f27bec1eac584e4a56507c610e8c3f41454609875c220c371722a842dd80e41d1c22a3e472fed2d423f7cd4034a0197ce0f76002f3d04d05bc9497cb8fb5d00a5e3ef78929816d7af102dbaeee6c991b7d1266dd552c78e796815d1d407537faf43cde87798d0374468f71585631e21cd13a9044b1ad60a2d91e185060fd54209f56fa8a022479e753db6fbeaaf060f99b2c8a1725c3f15124ad5711d715081772743b2236551f1954abd531ec2680534f5ccedd31840f29335b296c33e3c11b17474cf38d0f25ef9e130c3cd1817048e8702e785d3260ed095540720c27a1c6a89ce7d57d0ca5f2a2c033fb89e8d0f012693d2098d08c8b455cff771e39e0b1830e01ed35a4e2e34b842e8a34b4c434d010adaf2839eac45d825365919929013e46580a1ee64d73031acc97028b5d77b0e01d4edbe066ed6930681795d723844550a6f0f2fe258ea3dbd266919db5e874e05e08b0f31d849080490600a1de353635bce5f5ded2bae416bc9aea947eac0470a30359fe209a2aa58760b8641285f1a5942a5c8a1aea3f101bdf719295920a4cd0be7629baf5dbc850da978b46bbd21a9663093bee79b4fe96cb0548670560eb00db9f703f1a03937940bc14ff98cd3cad479d6ad18b613374183e9d21d6cc10703"
  }
}

After confirmation in a block, the GW address balance will be updated.

Node.js - full send flow (create + sign + broadcast):

async function sendFromGateway(gatewayAddressId, recipientAddress, amount, ownerWallet) {
  const createResult = await callDaemonRpc('gateway_create_transfer', {
    origin_gateway_id: gatewayAddressId,
    destinations: [
      { amount: amount - 1, address: recipientAddress },
      { amount: 1, address: recipientAddress }
    ],
    fee: 10000000000,
    comment: 'Payment from GW address',
  });

  console.log('TX hash to sign:', createResult.tx_hash_to_sign);

  // sign with ETH private key
  const bytesToSign = ethers.getBytes('0x' + createResult.tx_hash_to_sign);
  const signature = ownerWallet.signingKey.sign(bytesToSign);
  const ethSignature = signature.r.slice(2) + signature.s.slice(2);

  console.log('ETH signature (r||s):', ethSignature);

  const signResult = await callDaemonRpc('gateway_sign_transfer', {
    tx_blob: createResult.tx_blob,
    tx_hash_to_sign: createResult.tx_hash_to_sign,
    opt_ecdsa_signature: ethSignature,
  });

  console.log('Transaction signed successfully');

  const sendResult = await callDaemonRpc('sendrawtransaction', {
    tx_as_hex: signResult.signed_tx_blob,
  });

  console.log('Transaction broadcasted:', sendResult.status);
  return createResult.tx_hash_to_sign;
}

Sending TO a GW address

To send funds to a GW address, use the regular wallet RPC transfer - just specify gwZ... as the destination address:

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "transfer",
  "params": {
    "destinations": [
      {
        "amount": 2000000000000,
        "address": "gwZ5sqxb53vdd7RjUoPoSWeELimisUGprEX3P8fhcQwc8Dw6xpnSgfC1v"
      }
    ],
    "fee": 100000000
  }
}

Node.js - sending to a GW address:

async function sendToGateway(gwAddress, amount) {
  const result = await callWalletRpc('transfer', {
    destinations: [
      {
        amount: amount,
        address: gwAddress,
      },
    ],
    fee: 100000000,
  });

  console.log('Sent to GW address, tx_id:', result.tx_hash);
  return result.tx_hash;
}

5. Retrieving transaction history

Transaction history for a GW address is queried via daemon RPC using the gateway_get_address_history method.

IMPORTANT: you need your own daemon

The gateway_view_secret_key parameter is passed to the daemon to decrypt payment IDs and attachments (comments). If you use a public daemon, this key will be exposed to its operator. For full security, run your own node.

Request

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "gateway_get_address_history",
  "params": {
    "gateway_address": "gwZ5sqxb53vdd7RjUoPoSWeELimisUGprEX3P8fhcQwc8Dw6xpnSgfC1v",
    "gateway_view_secret_key": "f74bb56a...view secret key (64 hex, optional)",
    "offset": 0,
    "count": 50
  }
}

Parameters:

  • gateway_address - address gwZ... or gwiZ...
  • gateway_view_secret_key - secret view key (optional; without it payment_id won't be decrypted)
  • offset - pagination offset (starting from 0)
  • count - number of transactions (maximum 200 per request)

Response

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "status": "OK",
    "total_transactions": 2,
    "transactions": [
      {
        "tx_hash": "a85942c54afa26bb19ff77201d770d88956975e5c1c9ab72733ed17334122d6a",
        "height": 36322,
        "subtransfers_by_pid": [
          {
            "payment_id": "",
            "subtransfers": [
              {
                "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
                "is_income": true,
                "amount": 2000000000000
              }
            ]
          }
        ]
      },
      {
        "tx_hash": "20e922b32dfe9b8b6bc6004e40f4198c9e966d5e228cd4830656ba967f8a205c",
        "height": 36323,
        "subtransfers_by_pid": [
          {
            "payment_id": "",
            "subtransfers": [
              {
                "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
                "is_income": false,
                "amount": 1010000000000
              }
            ]
          }
        ],
        "comment": "Test transfer from GW"
      }
    ],
    "balances": [
      {
        "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
        "amount": 990000000000
      }
    ]
  }
}

Transactions are grouped by payment_id, and within each group - by asset_id. The balances field contains the current balances of the GW address at the time of the request.

Node.js - fetching history:

async function getGatewayHistory(gwAddress, viewSecretKey, offset = 0, count = 50) {
  const result = await callDaemonRpc('gateway_get_address_history', {
    gateway_address: gwAddress,
    gateway_view_secret_key: viewSecretKey,  // pass to decrypt payment IDs
    offset: offset,
    count: count,
  });

  console.log('Total transactions:', result.total_transactions);
  console.log('Current balances:', JSON.stringify(result.balances, null, 2));

  for (const tx of result.transactions) {
    console.log(`\nTX: ${tx.tx_hash} (block ${tx.height})`);
    if (tx.comment) console.log(`  Comment: ${tx.comment}`);

    for (const pidGroup of tx.subtransfers_by_pid) {
      console.log(`  Payment ID: ${pidGroup.payment_id || '(none)'}`);
      for (const sub of pidGroup.subtransfers) {
        console.log(`    Asset ${sub.asset_id}: ${sub.is_income ? '+' : '-'}${sub.amount}`);
      }
    }
  }

  return result;
}

How it works internally

  1. The blockchain maintains an index: for each GW address, a list of related transaction hashes is stored
  2. When a transaction is added to a block, all txin_gateway and tx_out_gateway referencing this GW address are automatically indexed
  3. When history is requested, the daemon retrieves transactions by index and for each one determines:
    • Incoming or outgoing transaction (by the presence of txin_gateway / tx_out_gateway)
    • Decrypts payment ID (if view_secret_key is provided)
    • Calculates balance changes per payment_id and asset_id

6. Integrated GW addresses (payment_id)

GW addresses support integrated addresses with embedded payment IDs, similar to regular Lethean addresses.

  • Integrated GW addresses start with gwiZ...
  • Payment ID: 8 bytes (16 hex characters)
  • Created via daemon RPC get_integrated_address:
{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "get_integrated_address",
  "params": {
    "regular_address": "gwZ5sqxb53vdd7RjUoPoSWeELimisUGprEX3P8fhcQwc8Dw6xpnSgfC1v",
    "payment_id": "1dfe5a88ff9effb3"
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "integrated_address": "gwiZ92c1bNYdd7RjUoPoSWeELimisUGprEX3P8fhcQwc8Dw6xpk6AP3Pv4KbyNZJdF3y",
    "payment_id": "1dfe5a88ff9effb3"
  }
}

When sending funds to a gwiZ... address, the payment ID is automatically encrypted and included in the transaction. To decrypt it when retrieving history, the view_secret_key is needed.

Node.js - creating an integrated address:

async function createIntegratedAddress(gwAddress, paymentId) {
  const result = await callDaemonRpc('get_integrated_address', {
    regular_address: gwAddress,
    payment_id: paymentId,
  });

  console.log('Integrated address:', result.integrated_address);
  console.log('Payment ID:', result.payment_id);
  return result.integrated_address;
}

7. Complete node.js example (end-to-end)

Below is a complete script that demonstrates the entire GW address lifecycle: key generation, registration, funding, transferring from GW, and reading history.

Prerequisites:

  • lethean-chain-node running and synced (port 36941)
  • lethean-wallet-cli running with RPC enabled (--rpc-bind-port=11112)
  • Wallet has at least ~110 LTHN for registration fee + test transfers
// npm i axios ethers @noble/ed25519
const axios = require('axios');
const { ethers } = require('ethers');
const ed = require('@noble/ed25519');

const WALLET_RPC_URL = 'http://127.0.0.1:11112/json_rpc';
const DAEMON_RPC_URL = 'http://127.0.0.1:36941/json_rpc';

const DEFAULT_FEE = 10_000_000_000;

// ed25519 subgroup order L
const ED25519_L = (1n << 252n) + 27742317777372353535851937790883648493n;

async function callWalletRpc(method, params = {}) {
  const response = await axios.post(WALLET_RPC_URL, {
    jsonrpc: '2.0', id: 0, method, params,
  });
  if (response.data.error) {
    throw new Error(`Wallet RPC [${method}]: ${JSON.stringify(response.data.error)}`);
  }
  return response.data.result;
}

async function callDaemonRpc(method, params = {}) {
  const response = await axios.post(DAEMON_RPC_URL, {
    jsonrpc: '2.0', id: 0, method, params,
  });
  if (response.data.error) {
    throw new Error(`Daemon RPC [${method}]: ${JSON.stringify(response.data.error)}`);
  }
  return response.data.result;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function assertOk(result, methodName) {
  if (result.status && result.status !== 'OK') {
    throw new Error(`${methodName} returned status: ${result.status}`);
  }
}

async function waitForNextBlock(timeoutMs = 1800000) {
  const startInfo = await callDaemonRpc('getinfo');
  const startHeight = startInfo.height;
  console.log(`  current height: ${startHeight}, waiting for next block...`);
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await sleep(5000);
    const info = await callDaemonRpc('getinfo');
    if (info.height > startHeight) {
      console.log(`  new height: ${info.height}`);
      return info.height;
    }
  }
  throw new Error(`Timeout waiting for next block (stuck at height ${startHeight})`);
}

function randomScalarLtL() {
  while (true) {
    const b = ethers.randomBytes(32);
    const x = BigInt('0x' + Buffer.from(b).toString('hex'));
    const s = x % ED25519_L;
    if (s !== 0n) return s;
  }
}

function scalarToLe32Hex(s) {
  const out = Buffer.alloc(32, 0);
  let x = s;
  for (let i = 0; i < 32; i++) {
    out[i] = Number(x & 0xffn);
    x >>= 8n;
  }
  return out.toString('hex');
}

function makeValidGwViewKeypair() {
  const scalar = randomScalarLtL();
  const viewSecretKey = scalarToLe32Hex(scalar);
  const viewPubKey = Buffer.from(ed.Point.BASE.multiply(scalar).toBytes()).toString('hex');
  return { viewSecretKey, viewPubKey };
}

async function main() {
  try {
    console.log('=== Key Generation ===');

    const { viewSecretKey, viewPubKey } = makeValidGwViewKeypair();
    const ownerWallet = ethers.Wallet.createRandom();
    const ownerEthPubKey = ethers.SigningKey.computePublicKey(ownerWallet.privateKey, true).substring(2);

    console.log('View public key:', viewPubKey);
    console.log('View secret key:', viewSecretKey);
    console.log('Owner ETH private key:', ownerWallet.privateKey);
    console.log('Owner ETH public key:', ownerEthPubKey);

    console.log('\n=== Registration (fee 100 LTHN) ===');

    const regResult = await callWalletRpc('register_gateway_address', {
      view_pub_key: viewPubKey,
      descriptor_info: {
        opt_owner_ecdsa_pub_key: ownerEthPubKey,
        meta_info: 'Example GW address for documentation',
      },
    });

    const gwAddress = regResult.address;
    const gwAddressId = regResult.address_id;
    console.log('GW address:', gwAddress);
    console.log('Address ID:', gwAddressId);
    console.log('Registration TX:', regResult.tx_id);

    console.log('\nWaiting for block confirmation...');
    await waitForNextBlock();

    const addressInfo = await callDaemonRpc('gateway_get_address_info', {
      gateway_address: gwAddress,
    });
    assertOk(addressInfo, 'gateway_get_address_info');
    console.log('Registration confirmed!');
    console.log('Descriptor:', JSON.stringify(addressInfo.descriptor_info, null, 2));
    console.log('Balances:', JSON.stringify(addressInfo.balances));

    console.log('\n=== Sending 2 LTHN to GW address ===');

    const sendToResult = await callWalletRpc('transfer', {
      destinations: [{ amount: 2_000_000_000_000, address: gwAddress }],
      fee: DEFAULT_FEE,
    });
    console.log('Sent to GW, tx_hash:', sendToResult.tx_hash);

    console.log('Waiting for block confirmation...');
    await waitForNextBlock();

    const updatedInfo = await callDaemonRpc('gateway_get_address_info', {
      gateway_address: gwAddress,
    });
    assertOk(updatedInfo, 'gateway_get_address_info');
    console.log('GW balances after funding:', JSON.stringify(updatedInfo.balances, null, 2));

    console.log('\n=== Sending 1 LTHN from GW address ===');

    const walletInfo = await callWalletRpc('getaddress');
    const recipientAddress = walletInfo.address;
    console.log('Recipient (own wallet):', recipientAddress);

    const totalAmount = 1_000_000_000_000;
    const createResult = await callDaemonRpc('gateway_create_transfer', {
      origin_gateway_id: gwAddressId,
      destinations: [
        { amount: totalAmount - 1, address: recipientAddress },
        { amount: 1, address: recipientAddress },
      ],
      fee: DEFAULT_FEE,
      comment: 'Test transfer from GW',
    });
    assertOk(createResult, 'gateway_create_transfer');
    console.log('TX hash to sign:', createResult.tx_hash_to_sign);

    // sign with ETH private key (ECDSA secp256k1, low-s normalized)
    const bytesToSign = ethers.getBytes('0x' + createResult.tx_hash_to_sign);
    const sig = ownerWallet.signingKey.sign(bytesToSign);
    const ethSignature = sig.r.slice(2) + sig.s.slice(2); // r||s, 128 hex = 64 bytes
    console.log('Signature (128 hex):', ethSignature);

    const signResult = await callDaemonRpc('gateway_sign_transfer', {
      tx_blob: createResult.tx_blob,
      tx_hash_to_sign: createResult.tx_hash_to_sign,
      opt_ecdsa_signature: ethSignature,
    });
    assertOk(signResult, 'gateway_sign_transfer');
    console.log('Transaction signed, blob length:', signResult.signed_tx_blob.length);

    const broadcastResult = await callDaemonRpc('sendrawtransaction', {
      tx_as_hex: signResult.signed_tx_blob,
    });
    if (broadcastResult.status !== 'OK') {
      throw new Error(`Broadcast failed: ${broadcastResult.status}`);
    }
    console.log('Broadcast OK!');

    console.log('Waiting for block confirmation...');
    await waitForNextBlock();

    console.log('\n=== Creating integrated GW address ===');

    const integratedResult = await callDaemonRpc('get_integrated_address', {
      regular_address: gwAddress,
      payment_id: '1dfe5a88ff9effb3',
    });
    console.log('Integrated address:', integratedResult.integrated_address);
    console.log('Payment ID:', integratedResult.payment_id);

    console.log('\n=== Fetching transaction history ===');

    const history = await callDaemonRpc('gateway_get_address_history', {
      gateway_address: gwAddress,
      gateway_view_secret_key: viewSecretKey,
      offset: 0,
      count: 50,
    });

    console.log('Total transactions:', history.total_transactions);
    console.log('Current balances:', JSON.stringify(history.balances, null, 2));

    for (const tx of history.transactions) {
      console.log(`\nTX: ${tx.tx_hash} (block ${tx.height})`);
      if (tx.comment) console.log(`  Comment: ${tx.comment}`);
      for (const pidGroup of tx.subtransfers_by_pid) {
        console.log(`  Payment ID: ${pidGroup.payment_id || '(none)'}`);
        for (const sub of pidGroup.subtransfers) {
          console.log(`    Asset ${sub.asset_id}: ${sub.is_income ? '+' : '-'}${sub.amount}`);
        }
      }
    }

    console.log('\nDone!');
  } catch (error) {
    console.error('\nERROR:', error.message);
    if (error.response) {
      console.error('Response data:', JSON.stringify(error.response.data, null, 2));
    }
  }
}

main();

API quick reference

Method RPC type Description
register_gateway_address Wallet RPC Register a new GW address (fee: 100 LTHN)
gateway_get_address_info Daemon RPC Get information and balances of a GW address
gateway_create_transfer Daemon RPC Create an unsigned transaction from a GW address
gateway_sign_transfer Daemon RPC Sign a transaction with owner key
sendrawtransaction Daemon RPC Broadcast a signed transaction to the network
gateway_get_address_history Daemon RPC Get GW address transaction history (requires view key for decryption)
get_integrated_address Daemon RPC Create an integrated gwiZ... address with payment ID
transfer Wallet RPC Send funds TO a GW address (standard method)

JWT Authentication Guide

What is JWT?

JWT (JSON Web Token) is a compact, URL-safe means of representing claims to be transferred between two parties. It is widely used for authentication and authorization in web applications.

A JWT typically consists of three parts:

  1. Header contains the type of the token (JWT) and the signing algorithm (HS256, etc.).
  2. Payload contains the claims, i.e., data such as user, exp (expiration), etc.
  3. Signature a hash of the header and payload, signed using a secret key.

The final token is encoded as:

header.payload.signature

Use Case in Our Project

We use JWT authentication to secure HTTP requests to our local JSON-RPC API betweedn Lethean desktop App and Lethean Extension (Lethean Companion). Each request includes a signed JWT in the Lethean-Access-Token header, which is verified by the server. You can enable JWT authentification in lethean-wallet-cli as well by adding --jwt-secret=hsjejkcdskndspo230XASIijksk123i9x5 when lethean-wallet-cli run in server mode (with --rpc-bind-port=PORT_NUM option).


Code example on nodejs

Here is a review of example of using JWT auth to call getbalance function from wallet.

Dependencies

  • axios for sending HTTP requests.
  • node-forge for cryptographic operations (HMAC, SHA-256, etc.).

Install them:

npm install axios node-forge

JWT Generation Flow

1. Shared Secret

Define your shared JWT secret (must match the server's configuration):

const JWT_SECRET = 'hiwejkcddkndspo230XASIijksk123i9x5';

2. Create JWT Token

function createJWSToken(payload, secret) {
  const header = { alg: 'HS256', typ: 'JWT' };
  const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64').replace(/=/g, '');
  const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64').replace(/=/g, '');

  const signature = forge.hmac.create();
  signature.start('sha256', secret);
  signature.update(`${encodedHeader}.${encodedPayload}`);
  const encodedSignature = forge.util.encode64(signature.digest().getBytes()).replace(/=/g, '');

  return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
}

3. Generate Body Hash

Before creating the token, we hash the request body:

const md = forge.md.sha256.create();
md.update(httpBody);
const bodyHash = md.digest().toHex();

4. Build Payload

The payload contains:

  • body_hash: hash of the request body.
  • user: fixed identifier.
  • salt: random 64-character string to avoid replay attacks.
  • exp: expiration timestamp (e.g., 60 seconds from now).
const payload = {
  body_hash: bodyHash,
  user: 'lethean_extension',
  salt: generateRandomString(64),
  exp: Math.floor(Date.now() / 1000) + 60,
};

5. Generate Final JWT

const token = createJWSToken(payload, JWT_SECRET);

Sending the Authenticated Request

axios.post('http://127.0.0.1:11111/json_rpc', requestData, {
  headers: {
    'Content-Type': 'application/json',
    'Lethean-Access-Token': token,
  },
})
.then(response => {
  console.log('Response:', response.data);
})
.catch(error => {
  if (error.response) {
    console.error('Error Response:', error.response.status, error.response.data);
  } else {
    console.error('Request Error:', error.message);
  }
});

Notes

  • Ensure your server validates:
    • JWT signature using the same secret (HS256).
    • exp is not expired.
    • body_hash matches actual request body content.
  • JWT tokens should be short-lived (e.g., 60 seconds) to reduce replay attack risks.

Example JSON-RPC Request

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "getbalance",
  "params": {}
}

Summary

JWT provides a secure and compact mechanism for authenticating API requests. In this project, we:

  • Hash the request body.
  • Create a signed JWT with a time-limited payload.
  • Send it in the Lethean-Access-Token HTTP header.

This ensures authenticity, integrity, and freshness of every client request.


Exchange integration full guide

Introduction

Lethean is a privacy-oriented blockchain from the CryptoNote family, which makes working with the wallet somewhat different from traditional blockchains like Bitcoin or Ethereum. In this article, we will show how to work with the wallet and how to build multi-user custody on it.

Architecturally, Lethean consists of two modules - a full node(daemon) and a wallet. Both of these modules provide their own RPC API. Therefore, when you set up Lethean on your server, you compile the full node (make target “daemon”, executable name lethean-chain-node) and the console wallet (lethean-wallet-cli), and run both, so that the wallet can connect to the full node through the RPC API via localhost (For security reasons, it is highly recommended to use ONLY your own full node).

Recommended security practice: run the wallet process (lethean-wallet-cli) on an internal host that has no Internet connectivity. The only outbound connection this host should make is to the fullnode RPC service (lethean-chain-node, port 36941 by default). Keeping the machine that holds your private keys completely isolated from the public network greatly reduces your attack surface and provides a markedly more secure setup.

Thus, the RPC API in Lethean is divided into two parts - the DAEMON RPC API and the WALLET RPC API. This is due to the fact that, unlike EVM or Bitcoin networks, you cannot simply request the balance of a specific address from the Lethean node. To get the balance of a specific address, you need to know its secret key and perform computationally complex operations. Therefore, there is a process of synchronizing the wallet with the daemon. If you have a wallet created, for example, a year or two ago and you haven't opened it for a long time or have restored it, the synchronization process may take some time. If the wallet was online a few days ago, the synchronization happens quickly - less than a minute.

Lethean is a platform where anyone can deploy their own asset, which will have the same privacy features as Lethean itself. Such assets are called Confidential Assets. Support for Confidential Assets is reflected in the API documentation and in this manual. Each asset has an identifier (asset_id), and only this asset_id identify this specific asset. All other attributes of the asset may match similar attributes of other assets

Custom transaction generation process and TSS

Some exchanges and custody services use their own frameworks for working with cryptocurrencies, which require manually constructing transactions. This approach generally works well for non-privacy blockchains such as Ethereum or Bitcoin with regular ECDSA signatures, but it can be extremely challenging with Lethean. As a privacy coin, a Lethean transaction includes a sophisticated set of proofs and signatures that secure it. As far as we are aware, there are no complete implementations of the Lethean transaction-generation process and its proofs in languages other than the C++ reference implementation used in the official wallet codebase. If, for any reason, you still want to implement a manual process for creating Lethean transactions, please review the following materials to understand the mathematics behind these proofs:

Creating a Wallet

To create a new wallet, you need to run the following command (you will be prompted to enter a new password for the wallet; do not use simple passwords and make sure to remember this password):

~/lethean/build # src/lethean-wallet-cli --generate-new-wallet=custody_wallet.zan
Lethean lethean-wallet-cli v2.0.0.333
password: *******
Generated new wallet: iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY
view key: f665686bbc719569e9f6c1e36058dcda011ddd55a584443b64c1e7bca5bbdd04
**********************************************************************
Your wallet has been generated.
**********************************************************************

A wallet can operate in two modes - command line interface mode, when it is started only with the --wallet-file parameter, or RPC server mode, when in addition it has the --rpc-bind-port=port_number parameter. When the wallet is in command line mode, you can do various commands to it, such as transfer or deploy_new_asset, and thus work with the wallet. You can view the list of commands by typing help in command line mode.

Creating a Wallet from custom seed phrase

If you want generate private keys from manually chosen words, you can pick 24 words from the list of words in this (source file) (keep in mind that this words are not compatible with bip39). Then you can use lethean-wallet-cli to extend this seed to standard Lethean seed phrase by using "--derive_custom_seed" command line option:

~/lethean/build # src/lethean-wallet-cli --derive_custom_seed

Seed phrase backup

After you have created a new wallet, run it in command line mode to save the seed phrase:

src/lethean-wallet-cli --wallet-file=custody_wallet.zan
Lethean lethean-wallet-cli v2.0.0.333
password: *******
Opened wallet: iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY
**********************************************************************
Use "help" command to see the list of available commands.
**********************************************************************
Starting refresh...
Refresh done, blocks received: 1440
 balance unlocked      / [balance total]        ticker   asset id
 0.0                                            LTHN     d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a
 [Lethean wallet iTHNUN]:

After the wallet has synchronized, enter the command show_seed. First, the wallet will ask for its own password for security reasons (the one you specified when creating the wallet). After this, you will be prompted to enter a special password that will protect your seed phrase. (More about Secure Seed) If you leave this password empty, an unprotected seed phrase will be generated, and anyone who gains access to the seed phrase will be able to control all assets.

[Lethean wallet iTHNUN]: show_seed
Enter password to confirm operation:
*****
Please enter a password to secure this seed. Securing your seed is HIGHLY recommended. Leave password blank to stay unsecured.
Remember, restoring a wallet from Secured Seed can only be done if you know its password.
Enter seed password: **********
Confirm seed password: **********
heart eat cost little goodbye arrive commit dreamer stick  reason freeze left okay cousin frustrate certainly focus town proud chin stretch difference easily content couple land
[Lethean wallet iTHNUN]:

!!! Be sure to save this seed phrase in a secure place. If the seed phrase is lost, the wallet may become impossible to restore, and all assets may be lost.

Once youve backed up your seed phrase, you can launch the wallet in server mode for future operations:

src/lethean-wallet-cli --wallet-file=custody_wallet.zan --rpc-bind-port=12345 --daemon-address=192.168.1.3:36941

Receiving Money with Payment ID

Each wallet file in Lethean is always one address and one secret key (in fact, it's a two secrete keys, but this is not important in the context of this manual). Lethean does not support HD wallets for a number of technical reasons. Instead, for multi-user support, a so-called payment_id is used, which is a special identifier associated with the user. Each incoming transaction that contains this payment_id is considered credited to the balance of this user. Typically, a payment_id is an 8-byte random number generated by an exchange (or another custody service). It can be up to 128 bytes, but it is usually 8 bytes.

IMPORTANT: Users should never "operate" their payment_id anywhere under any circumstances. Instead, an integrated address is used. An integrated address is a special address format that encodes the user's payment_id along with the base wallet address, eliminating errors or typos. To generate an integrated address, you can use the WALLET RPC API make_integrated_address (similar API present in daemon get_integrated_address ):

   Request:
    {
     "id": 0,
     "jsonrpc": "2.0",
     "method": "make_integrated_address",
     "params": {
       "payment_id": "1dfe5a88ff9effb3"
     }
    }

   Response:
    {
     "id": 0,
     "jsonrpc": "2.0",
     "result": {
       "integrated_address": "iZ2EEMZWeKBRvbHrvebi5fgBLXDWukJ3VRXk6PENQ4orUTRfh11EHjCgCBxokeg5FEPHumvqJ76ikKHnD43iGjsECvV53PeAEkM3CLGRmST3",
       "payment_id": "1dfe5a88ff9effb3"
     }
    }

An address that starts with a lowercase letter "i" is an Integrated Address. It is always longer than a regular address and looks something like this: "iZ2EEMZWeKBRvbHrvebi5fgBLXDWukJ3VRXk6PENQ4orUTRfh11EHjCgCBxokeg5FEPHumvqJ76ikKHnD43iGjsECvV53PeAEkM3CLGRmST3". Only such an address can be shown to the user as their own deposit address. Transactions sent to this address will always have the payment_id specified when creating the address. Note: if payment_id is empty during the call of make_integrated_address then its generated as random of 8 bytes, please use this with caution since it might not be safe to use without potential collision verification (ie check every newly generated payment_id against existing users).

Processing Incoming Transactions

There are several ways to get information about transactions for a Lethean wallet. We will review most convenient and also mention other legacy approaches at the end.

The main method for obtaining transaction history information is get_recent_txs_and_info2. In the example below, we removed excessive and irrelevant for this article information from the response and left only those fields that are essential for processing custody.

Request:
{
 "id": 0,
 "jsonrpc": "2.0",
 "method": "get_recent_txs_and_info2",
 "params": {
   "count": 100,
   "exclude_mining_txs": false,
   "exclude_unconfirmed": true,
   "offset": 0,
   "order": "FROM_END_TO_BEGIN",
   "update_provision_info": true
 }
}
Response:
{
  "id": 0,
  "jsonrpc": "2.0",
  "result": {
    "pi":{
       "curent_height": 100010,
       },
    "last_item_index": 1,
    "total_transfers": 2,
    "transfers": [
      {
        "comment": "Comment here",
        "fee": 10000000000,
        "height": 100000,
        "payment_id": "1dfe5a88ff9effb3",
        "subtransfers": [
          {
            "amount": 1000000000000,
            "asset_id": "cc608f59f8080e2fbfe3c8c80eb6e6a953d47cf2d6aebd345bada3a1cab99852",
            "is_income": true
          },
          {
            "amount": 1000000000000,
            "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
            "is_income": true
          }
        ],
        "timestamp": 1712590951,
        "transfer_internal_index": 1,
        "tx_hash": "bfaf3abfd644095509650e12c8f901e6731a2e3e1366d3dbeddb0c394cd11db8",
        "unlock_time": 0
      },
      {
        "comment": "Comment here",
        "fee": 10000000000,
        "height": 100001,
        "payment_id": "1dfe5a88ff9effb3",
        "subtransfers": [
          {
            "amount": 1000000000000,
            "asset_id": "cc608f59f8080e2fbfe3c8c80eb6e6a953d47cf2d6aebd345bada3a1cab99852",
            "is_income": false
          },
          {
            "amount": 1000000000000,
            "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
            "is_income": true
          }
        ],
        "timestamp": 1712590951,
        "transfer_internal_index": 0,
        "tx_hash": "5509650e12c8f901e6731a2bfaf3abfd64409e3e1366d3d94cd11db8beddb0c3",
        "unlock_time": 0
      }
    ]
  }
}

You can read the transaction feed either from the oldest to the most recent (set "order" to "FROM_END_TO_BEGIN"), or vice versa - from the most recent to the oldest (set "order" to "FROM_BEGIN_TO_END"). Generally, when doing custody, you read the transaction feed from the wallet starting from the oldest transactions and read the entire history to the most recent transactions. To do this, set the "order" field in the request to "FROM_END_TO_BEGIN". If the response returns fewer elements than the "count" specified in the request (in our example, this is 100), it means that you have read the entire transaction history from end to the most recent transactions. If not, you need to continue calling get_recent_txs_and_info2 in such a way that each subsequent call passes the "offset" value, which indicates how many elements have already been read from the feed (if using the FROM_END_TO_BEGIN mode, you can also use the value from the "transfer_internal_index" field in the most recent element of the "transfers" array). Keep in mind that the number of transactions you count as transfers to users may differ from the total number of transactions read due to some transactions that you may decide to ignore as non-legit.

The list of transactions is in the "transfers" array. The response header has a pi.current_height field, which indicates the current highest known blockchain height to the wallet. Relative to this number, you will calculate the number of confirmations for each transaction in the "transfers" array (specifically, subtract the "height" field from pi.current_height field).

Each element in the "transfers" array represents a description of a transaction with its details. The important fields in this structure are:

  • "height": block number when transaction got included
  • "payment_id": "1dfe5a88ff9effb3" - user-associated identification, that was encoded in integrated address of the user. If transaction has valid payment_id, that means incoming payments from this transaction should go to user associated with this payment_id

Since Lethean is a multi-asset platform, each transaction might contain multiple transfers (different assets). The “subtransfers” array lists each asset that was part of this transaction:

  "subtransfers": [
    {
      "amount": 1000000000000,
      "asset_id": "cc608f59f8080e2fbfe3c8c80eb6e6a953d47cf2d6aebd345bada3a1cab99852",
      "is_income": false
    },
    {
      "amount": 1000000000000,
      "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
      "is_income": true
    }
  ]

IMPORTANT:

  • Do not account deposits for transactions that have not reached 10 confirmations. Sometimes the network undergoes reorganisation among the last 2-3 blocks. This is normal, and within this number of confirmations, the transaction sequence may change, including the removal of transactions that previously appeared with 2-3 confirmations. Read the history only until those transactions that got 10 confirmations, when it comes to transactions that haven't mach this number of transactions - re-read get_recent_txs_and_info2 until you see those transactions in response with 10 confirmations. Make your code fully aware of such situations and re-read history for those transactions.
  • Do not count on "remote_addresses" or "remote_aliases" fields, as those fields are optional and might be or might not be present in transactions, due to privacy nature of transactions.
  • Consider only those asset_id that you know, and ignore any others.
  • When depositing an asset, ensure the correct interpretation of the decimal point, as it may differ for each asset. You can request asset details via the DAEMON RPC API get_asset_info. Native coins have the asset_id d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a and should always be deposited for.
  • A transaction may contain both incoming and outgoing subtransfers. Check the is_income field for each element.
  • We also recommend specifying the "exclude_unconfirmed": true field in your request, as unconfirmed transactions are not important in the context of custody.
  • Do not deposit transactions where the "unlock_time" field is different from 0, as such transactions may be locked for a long period.
  • Over time, the payment_id may be pruned from old transactions history (over a year old), so backup the transfer history of your users to avoid future issues.

Requesting Wallet Balance

To request the current balance of the wallet, you can use the WALLET RPC API getbalance.

Request:
{
 "id": 0,
 "jsonrpc": "2.0",
 "method": "getbalance",
 "params": {
 }
}

Response:
{
  "id": 0,
  "jsonrpc": "2.0",
  "result": {
    "balances": [
      {
        "asset_info": {
          "asset_id": "f74bb56a5b4fa562e679ccaadd697463498a66de4f1760b2cd40f11c3a00a7a8",
          "current_supply": 500000000000000000,
          "decimal_point": 12,
          "full_name": "Lethean wrapped USD",
          "hidden_supply": false,
          "meta_info": "Stable and private",
          "owner": "f74bb56a5b4fa562e679ccaadd697463498a66de4f1760b2cd40f11c3a00a7a8",
          "ticker": "ZUSD",
          "total_max_supply": 1000000000000000000
        },
        "awaiting_in": 1000000000000,
        "awaiting_out": 2000000000000,
        "total": 100000000000000,
        "unlocked": 50000000000000
      },
      {
        "asset_info": {
          "asset_id": "d6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a",
          "current_supply": 13000000000000000000000,
          "decimal_point": 12,
          "full_name": "Lethean wrapped USD",
          "hidden_supply": false,
          "meta_info": "Stable and private",
          "owner": "",
          "ticker": "LTHN",
          "total_max_supply": 18000000000000000000000
        },
        "awaiting_in": 2000000000000,
        "awaiting_out": 1000000000000,
        "total": 500000000000000,
        "unlocked": 10000000000000
      }
    ]
  }
}

Response returns a list of assets that are present in the wallet, and for each asset, there are details ("asset_info") as well as the balances in the "total" and "unlocked" fields. The "unlocked" field shows how many coins are currently available for sending (this does not include incoming transactions that have not reached 10 confirmations, for example change). The "total" field shows all assets, including those that have not reached the required number of confirmations.

IMPORTANT: Assets not included in the public or private whitelist do not appear in the getbalance response. There is a public whitelist maintained by the project community, as well as a private whitelist for each wallet, which is stored in the wallet's file. If you want to see the balance of an asset not present in the public whitelist, you need to call the WALLET RPC API assets_whitelist_add:

Request:
{
 "id": 0,
 "jsonrpc": "2.0",
 "method": "assets_whitelist_add",
 "params": {
   "asset_id": "f74bb56a5b4fa562e679ccaadd697463498a66de4f1760b2cd40f11c3a00a7a8"
 }
}
Response:
{
 "id": 0,
 "jsonrpc": "2.0",
 "result": {
   "asset_descriptor": {
     "current_supply": 500000000000000000,
     "decimal_point": 12,
     "full_name": "Lethean wrapped USD",
     "hidden_supply": false,
     "meta_info": "Stable and private",
     "owner": "f74bb56a5b4fa562e679ccaadd697463498a66de4f1760b2cd40f11c3a00a7a8",
     "ticker": "ZUSD",
     "total_max_supply": 1000000000000000000
   },
   "status": "OK"
 }
}

Searching for Transactions in the Wallet

You can also search for an arbitrary transaction by its tx_id or using other available parameters in the API search_for_transactions2:

{
  "id": 0,
  "jsonrpc": "2.0",
  "method": "search_for_transactions2",
  "params": {
    "filter_by_height": true,
    "in": true,
    "max_height": 20000,
    "min_height": 11000,
    "out": true,
    "pool": false,
    "tx_id": "97d91442f8f3c22683585eaa60b53757d49bf046a96269cef45c1bc9ff7300cc"
  }
}

Transfer Coins

Transferring coins is done using the WALLET RPC API transfer:

Request:
{
 "id": 0,
 "jsonrpc": "2.0",
 "method": "transfer",
 "params": {
   "destinations": [{
     "address": "iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY",
     "amount": 10000000000000,
     "asset_id": "cc608f59f8080e2fbfe3c8c80eb6e6a953d47cf2d6aebd345bada3a1cab99852"
   }],
   "fee": 10000000000,
   "hide_receiver": true,
   "mixin": 15,
   "push_payer": false
 }
}
Response:
{
 "id": 0,
 "jsonrpc": "2.0",
 "result": {
   "tx_hash": "01220e8304d46b940a86e383d55ca5887b34f158a7365bbcdd17c5a305814a93",
   "tx_size": 1234,
   "tx_unsigned_hex": ""
 }
}

It is good practice to check that your balance is sufficient for sending the desired asset before making a transfer; otherwise, there may be an error in sending the transaction. Sometimes, you need to wait up to 10 minutes to gather the required number of confirmations for the change (if the value in the unlocked field is still less than the value in the total field in the balances response). If you received a transaction hash in the “tx_hash” field, it means the transaction has been successfully created and accepted by the daemon for relay across the network. Once this transaction is included in a block and starts getting confirmations, you will see it in the results of get_recent_txs_and_info2 (the is_income field will be false).

IMPORTANT: Before sending, be sure to validate address and also check that the address you are sending to does not belong to your wallet, even if it is an integrated address of another user on your base wallet. You cannot send transactions between users within the same wallet. To validate address and check the base wallet address from integrated address, you need to call the WALLET RPC API split_integrated_address:

Request:
{
 "id": 0,
 "jsonrpc": "2.0",
 "method": "split_integrated_address",
 "params": {
   "integrated_address": "iZ2EMyPD7g28hgBfboZeCENaYrHBYZ1bLFi5cgWvn4WJLaxfgs4kqG6cJi9ai2zrXWSCpsvRXit14gKjeijx6YPCLJEv6Fx4rVm1hdAGQFis"
 }
}
Response:
{
 "id": 0,
 "jsonrpc": "2.0",
 "result": {
   "payment_id": "1dfe5a88ff9effb3",
   "standard_address": "iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY"
 }
}

You need to first check that API managed to parse address, if "standard_address" is not empty then the address that you passed is valid. You also need to check the standard_address field and ensure it is different from your own custody wallet. If the fields match, it means an attempt is being made to perform an internal transfer within your own custody, from one user to another. Such internal transactions are typically handled offchain by well-designed services.

IMPORTANT: Sending to multiple destinations in a single transaction related with some limitations: any integrated address should be used only in single-destination transaction.

Legacy methods

get_bulk_payments get_payments


Signing transactions offline (cold-signing process)

Introduction

In order to provide more security it's possible to sign transactions offline using a dedicated wallet application instance e.g. running in a secure environment.

alt signing-transactions-offline-introduction

Lethean as a CryptoNote coin uses two key pairs (4 keys) per wallet: view key (secret+public) and spend key (secret+public)

So-called "hot wallet" (or watch-only wallet) uses only view secret key. This allows it to distinguish its transactions among others in the blockchain. To spend coins a wallet needs to spend secret key. It is required to sign a tx. Watch-only wallet doesn't have access to spend secret key and thus it can't spend coins.

If someone has your spend secret key, he can spend your coins. Master keys should be handled with care.

Setup

  1. In a secure environment create a new master wallet:

    i. Start lethean-wallet-cli to generate the master wallet: lethean-wallet-cli --generate-new-wallet=lethean_wallet_master
    (lethean_wallet_master is wallet's filename and can be changed freely)
    ii. Type in a password when prompted. An empty new wallet will be created.
    iii. Open the new wallet again: lethean-wallet-cli --offline-mode --wallet-file=lethean_wallet_master
    iv. In the wallet console, type the following command:
    save_watch_only lethean_wallet_watch_only WATCH_PASSWORD
    where WATCH_PASSWORD is the password for the watch-only wallet. You should see: Watch-only wallet has been stored to lethean_wallet_watch_only
    v. Type exit to quit lethean-wallet-cli.

  2. Copy lethean_wallet_watch_only file from the secure environment to your production environment where daemons and the hot wallet is supposed to be run.

CAUTION: lethean_wallet_master file contains master wallet's private keys! You may want to ensure it never leaves the secure environment.

  1. In the production environment start the daemon. Let it sync with the network if running for the first time and make sure it gets synchronized. Then, start the watch-only wallet:
    lethean-wallet-cli --wallet-file=lethean_wallet_watch_only --password=WATCH_PASSWORD --rpc-bind-ip=RPC_IP --rpc-bind-port=RPC_PORT --daemon-address=DEAMON_ADDR:DAEMON_PORT --log-file=LOG_FILE_NAME (see also the Introduction; for the first run you may add --log-level=0 to avoid too verbose messages, for subsequent runs you may want to use --log-level=1 or --log-level=2)

The setup is complete.

Example of a transaction cold-signing

In order to sign a transaction, follow these steps:

  1. Create a transaction using RPC transfer.

Because of using watch-only wallet keys for this instance of wallet application (please note passing lethean_wallet_watch_only in i.3) a transaction will not be signed and broadcasted. Instead, an unsigned transaction will be prepared and returned back to the caller via RPC.

RPC example (please, see also transfer RPC description in "List of RPC calls" section above):

$ curl http://127.0.0.1:12233/json_rpc -s -H 'content-type:application/json;' --data-binary '{"jsonrpc":"2.0","id":"0","method":"transfer", "params":{   "destinations":[{"amount":1000000000000, "address":"iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY"}], "fee":10000000000, "mixin":0   }}'
{
  "id": "0",
  "jsonrpc": "2.0",
  "result": {
    "tx_hash": "",
    "tx_size": 0,
    "tx_unsigned_hex": "00-LONG-HEX-00"
  }
}

Unsigned transaction data retrieved in tx_unsigned_hex field should be passed to the secure environment for cold-signing by the master wallet.

  1. Run then master wallet in RPC mode within a secure environment:
    lethean-wallet-cli --wallet-file=lethean_wallet_master --offline-mode --rpc-bind-port=RPC_PORT --rpc-bind-ip=RPC_IP (note that the master wallet is running in offline mode and doesn't need access to the Internet or Lethean daemon).

  2. Using RPC sign_transfer sing the transaction using the master wallet.

RPC example:

$ curl http://127.0.0.1:12233/json_rpc -s -H 'content-type:application/json;' --data-binary '{"jsonrpc":"2.0","id":"0","method":"sign_transfer", "params":{  "tx_unsigned_hex" : "00-LONG-HEX-00" }'
{
  "id": "0",
  "jsonrpc": "2.0",
  "result": {
    "tx_hash": "864dc39fe1f1440651a9c2cc0585ba2f91498778bae86583d37fcc0b251aea4a",
    "tx_signed_hex": "00-LONG-HEX-00"
  }
}

A signed transaction retrieved in tx_signed_hex field should be passed back to the production environment to be broadcasted by the watch-only hot wallet. NOTE: Please, don't sign more then one time the same "tx_unsigned_hex", as you'll get two transactions with different tx_id but spending the same key_images, which will lead to errors.

  1. Using RPC submit_transfer broadcast the transaction via watch-only wallet.

RPC example:

$ curl http://127.0.0.1:12233/json_rpc -s -H 'content-type:application/json;' --data-binary '{"jsonrpc":"2.0","id":"0","method":"submit_transfer", "params":{ "tx_signed_hex": "00-LONG-HASH-00"  }'
{
  "id": "0",
  "jsonrpc": "2.0",
  "result": {
    "tx_hash": "864dc39fe1f1440651a9c2cc0585ba2f91498778bae86583d37fcc0b251aea4a"
  }
}

The transaction is successfully broadcasted over the network.

Important note on watch-only wallets

Watch-only wallet is not able naturally to calculate a balance using only a tracking view secret key and an access to the blockchain. This happens because it can't distinguish spending its own coins as it requires knowing key images for own coins, which are unknown, as key image calculation requires spend secret key.

To workaround this difficulty watch-only wallet extracts and stores key images for own coins each time a signed transaction from a cold wallet is broadcasted using submit_transfer RPC. This data is stored locally in .outkey2ki file and it is required to calculate wallet's balance in case of full wallet resync.

It's important to keep this data safe and not to delete watch-only wallet's files (including .outkey2ki). Otherwise, watch-only wallet won't be able to calculate its balance correctly and the master wallet may be required to be connected online for recovering funds.

Please make sure, whenever you shutdown the watch only wallet - close it gracefull, use SIGINT, SIGTERM or ctrl+c and let it store the wallet state, or close ["store"] (https://testnet-docs.lthn.io/docs/build/rpc-api/wallet-rpc-api/store/) wallet RPC method before you kill the lethean-wallet-cli process.

If it's happen that you lost or damaged outkey2ki, try restoring the watch only wallet:

Restoring the watch only wallet

  1. Make sure you're running build 2.1.8.414 or more recent.

  2. Stop all lethean-wallet-cli processes in all environments.

  3. Move watch-only wallet file (lethean_wallet_watch_only) to the secure environment, where the master wallet file is located.

  4. In the secure environment run the following command:

    lethean-wallet-cli --offline-mode --wallet-file=lethean_wallet_master --restore-ki-in-wo-wallet=lethean_wallet_watch_only

  5. Enter master wallet's password and watch-only wallet password when prompted.

  6. Make sure it finished successfully.

  7. Move lethean_wallet_watch_only and lethean_wallet_watch_only.outkey2ki files to the production environment.

Now watch-only wallet, once fully synced, should show the correct balance.

As a last resort you can always move you master wallet to the production environment, run it online (skipping --offline-mode), sync it and transfer all the funds manually to a newly created cold/hot wallet as described above.


Exchange FAQ

Frequently asked questions for exchanges and services

Why don't you compile your code and just send binaries to us?

Our project is open source and we believe this would be insecurity in its extreme form.

Ubuntu 18.04 LTS or Ubuntu 16.04 LTS

We got "internal compiler error: Killed"

You ran out of RAM. Try to limit make job slots by specifying make -j 1

We got compiler/linker error mentioning Boost

Make sure you have built the recommended version of Boost manually (via ./bootstrap.sh, ./b2). Please refer to our github page for reference.

Wallet RPC is not working. We got "Core is busy" in logs/responses

Make sure the daemon is synchronized with the network. It may take up to few hours when running for the fist time. When it synchronized you'll stop seeing yellow "sync progress" messages in daemon logs.

How to validate an address?

To validate an address you can use split_integrated_address. It also works with standard addresses (non integrated one)

How to get all integrated addresses on a wallet?

A wallet does not store all integrated addresses, thus it is impossible. Integrated address is just your wallet address PLUS encoded hex payment id you provided packed together. As you can provide ANY payment id you could imagine, you can generate unlimited number of integrated addresses for a wallet.

Can we use random payment id when generating integrated address for a user?

Yes, It is highly recommended to use randomly generated payment id's to identify each of your users.

What transaction fee should we specify in RPCs?

Minimum transaction fee is 0.01 LTHN.

What are "pub_keys" that we see in transaction output via explorer?

ach output in CryptoNote-like currency has it's own public key (i.e. one-time destination key) that cannot be linked with the address or other outputs. The owner of the output calculates the private key when he spends it. Please, refer to the CryptoNote whitepaper page 7 for details.

We got "Invalid params" RPC response

Make sure you pass amounts as integers not strings.

We can't see our own transfer when filtering by payment id with get_bulk_payments or get_payments RPC calls

Make sure you're not sending coins to yourself (from an address to the very same address). Coins which were sent that way will safely reach their destination (and the balances will be correct) but such a transfer won't be seen when you filer transfers by payment id via get_bulk_payments or get_payments.