Перейти к содержанию

Self-Custody use case (BTC-like)

Данный гайд иллюстрирует создание секретов, синхронизацию адресов, получение баланса и отправку транзакции из клиентского приложения-кошелька в рамках сценария Self-Custody с использованием Spatium Signer Service.

Инициализация

Для взаимодействия со Spatium Signer Client необходимо предоставить следующие данные:

  • auth - сессия авторизации (AuthorizationSession)
  • crypto - любой валидный Crypto для обработки данных
import { MemoryStorageDriver, SpatiumCrypto } from '@spatium/sdk';
import { AuthorizationSession, ServiceStorage, SignerClient } from '@spatium/signer-client';

export const createSignerClient = (auth: AuthorizationSession) => {
    const storage = new ServiceStorage('https://cloud.spatium.net/storage/v1', auth);

    const cache = new MemoryStorageDriver()
    const crypto = new SpatiumCrypto(cache, storage)

    return new SignerClient('https://cloud.spatium.net/signer/v1', auth, crypto, 10 * 1000);  
};

Важно! В данном примере в качестве хранилища используется ServiceStorage, который размещает оба секрета на стороне Spatium. Использование данного формата подразумевает, что кошелек является кастодиальным. Не рекомендуется к использованию в продакшене.

Генерация секрета

Для пользования распределенным кошельком необходимо создать постоянную пару из клиентского и серверного секретов и обеспечить их надежное хранение. На стороне Spatium Signer Service управление секретом происходит автоматически, а на стороне клиента разработчик должен имплементировать стабильный StorageDirver самостоятельно. В обоих случаях секрет привязывается к идентификатору секрета (secretId) и после создания доступен по нему. У одного пользователя может быть любое количество секретов, однако, так как нужно обеспечить их сохранность и возможность восстановления, рекомендуется использовать один секрет на пользователя.

Для резервного копирования секретов на случай утраты содержимого StorageDirver рекомендуется использовать функционал экспорта и импорта секретов.

export const ensureSecret = async (signerClient: SignerClient, secretId: string) => {
  if (await signerClient.crypto.checkSecret(secretId)) {
    return;
  }

  try {
    // Ожидаем установки соедения 
    await signerClient.connect(10 * 1000);

    await signerClient.generateDistributedSecret(secretId);
  } finally {
    await signerClient.disconnect();
  }
};

Важно! На данном этапе разработки SDK рекомендуется использовать подобный подход при обращении к сервису, т.е. с подключением непосредственно перед взаимодействием и отключением после. Это позволит избежать распространенных сетевых ошибок пока они не будут полностью устранены.

Синхронизация адресов валют

Адрес валюты необходим для получения ассетов и запроса информации о балансе, поэтому рекомендуется сразу синхронизировать его при создании кошелька.

Для уменьшения времени синхронизации и улучшения пользовательского опыта рекомендуется использовать один публичный ключ (одни параметры синхронизации) для всех валют одной и той же криптосистемы. Для этого следует сначала синхронизировать один публичный ключ соответствующей криптосистемы, а затем сгенерировать с его помощью адреса в нужных валютах.

Каждая новая процедура синхронизации привязывается к уникальному идентификатору и ее результаты записываются в предоставленный StorageDirver, что дает возможность (имея сохраненный syncSessionId) синхронизировать ключ один раз и затем пользоваться результатами постоянно. Однако утрата данных синхронизации не несет долгосрочных последствий, так как ничего не мешает провести синхронизацию еще раз и получить при этом тот же самый публичный ключ и, соответственно, адреса.

Для процедуры синхронизации необходимы следующие данные:

  • secretId - идентификатор секрета, служащего энтропией данного кошелька. На момент синхронизации секреты должны быть уже сгенерированы;
  • syncSessionId - идентификатор сессии синхронизации. В случае совпадения предыдущая сессия с таким идентификатором будет перезаписана;
  • curve - эллиптическая кривая. Для всех валют, поддерживаемых на текущий момент, это secp256k1;
  • derivationCoin - параметр HD деривации ключа, непосредственно влияет на результат генерации адреса. Уникальные значения приводят к генерации уникальных ключей. Рекомендуется использовать фиксированное значение для конкретной криптосистемы, а варьировать значение ключа следующим параметром;
  • derivationAccount - параметр HD деривации ключа, непосредственно влияет на результат генерации адреса. Уникальные значения приводят к генерации уникальных ключей.
import { syncDistributedEcdsaKey, getEcdsaPublicKey } from '@spatium/sdk';

export const ensureEcdsaPublicKey = async (signerClient: SignerClient, secretId: string, syncSessionId: string, derivationCoin: number, derivationAccount: number): Promise<string> => {
  const publicKey = await getEcdsaPublicKey(signerClient, secretId, syncSessionId).catch(() => null);
  if (publicKey) {
    return publicKey;
  }

  try {
    // Ожидаем установки соедения 
    await signerClient.connect(10 * 1000);

    const distributedEcdsaKey = await syncDistributedEcdsaKey(signerClient, secretId, syncSessionId, 'secp256k1', derivationCoin, derivationAccount);
    return distributedEcdsaKey
  } finally {
    await signerClient.disconnect();
  }
};

Для получения из публичного ключа адреса в конкретном блокчейне рекомендуется использовать Blockchain Connector Service.

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeGetAddressRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  type?: 'p2pkh' | 'p2wpkh';
  prefix?: boolean;
};
type BtcLikeGetAddressResponse = {
  requestId: string;
  data: { address: string };
};

export const btcLikeGetAddress = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, publicKey, type, prefix }: BtcLikeGetAddressRequest,
): Promise<BtcLikeGetAddressResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.post(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/get-address/${chain}`,
    {
      publicKey,
      network,
      type,
      prefix,
    },
    {
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response.data;
};

Важно! Для того, чтобы при утрате данных в StorageDirver можно было однозначно восстановить все адреса со всеми средствами пользователя, нужно обеспечить бэкап клиентского секрета и внешнее хранение параметров генерации адресов, а именно:

  • secretId - данные генерации, также нужно хранить вместе с бэкапом секрета
  • curve - данные генерации
  • derivationCoin - данные генерации
  • derivationAccount - данные генерации

Получение информации об адресе

Имея синхронизированный адрес (или любой другой) можно обратиться к Address Info Service для получения развернутой информации об адресе, включая баланс в различных ассетах и историю транзакций.

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type AddressInfo = {
  address: string;
  chain: string;
  balances: [
    {
      asset: { chain: string, kind: string },
      balance: { amount: string, decimals: number }  
    }
  ];
  transactions: [];
}
type BtcLikeGetAddressInfoRequest = {
  network?: 'livenet' | 'testnet';
  address: string;
};
type BtcLikeGetAddressInfoResponse = {
  requestId: string;
  data: AddressInfo;
};

export const btcLikeGetAddressInfo = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, address }: BtcLikeGetAddressInfoRequest,
): Promise<BtcLikeGetAddressInfoResponse['data']> => {
  const token = await auth.getPermissionToken();

  const result = await axios.get(
    `https://cloud.spatium.net/address-info-btc-like/v1/api/${chain}`,
    {
      params: { network, address },
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return result.data;
};

Подпись транзакции

Подпись транзакции включает в себя несколько этапов:

  • Сбор текущих данных из блокчейна, например, UTXO или nonce;
  • Оценка комиссии транзакции;
  • Формирование хэша транзакции;
  • Подпись хэша транзакции;
  • Формирование подписанной транзакции;
  • Отправка транзакции в блокчейн.

Из всех этих этапов только подпись хэша транзакции выполняется при помощи SDK, остальное предоставляется через API Blockchain Connector Service.

Сбор UTXO

Биткоин-подобные валюты для формирования транзакции требуют актуальных на текущий момент UTXO (Unspent Transaction Outputs).

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeGetUTXORequest = {
  network?: 'livenet' | 'testnet';
  address: string;
};
type UTXO = {
  txid: string;
  vout: number;
  value: string;
};
type BtcLikeGetUTXOResponse = {
  requestId: string;
  data: { utxo: UTXO[] };
};

export const btcLikeGetUTXO = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, address }: BtcLikeGetUTXORequest,
): Promise<BtcLikeGetUTXOResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.get(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/prepare-transaction/utxo-list/${chain}`,
    {
      params: { network, address },
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response.data;
};

Оценка размера транзакции

Для оценки размера комиссии в биткоин-подобных блокчейнах используется размер транзакции в байтах, для получения которого также нужно знать UTXO.

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeGetTXSizeRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  type?: 'p2pkh' | 'p2wpkh';
  utxo: { txid: string; vout: number; value: string }[];
  to: string;
  amount: string;
};
type BtcLikeGetTXSizeResponse = { 
  requestId: string;
  data: { size: number };
};

export const btcLikeGetTXSize = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, publicKey, type, utxo, to, amount }: BtcLikeGetTXSizeRequest,
): Promise<BtcLikeGetTXSizeResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.post(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/prepare-transaction/size/${chain}`,
    {
      publicKey,
      network,
      type,
      utxo,
      to,
      amount,
    },
    {
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response.data;
};

Получение информации о средней комиссии сети

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeGetBytePricesRequest = {
  network?: 'livenet' | 'testnet';
};
type BtcLikeGetBytePricesResponse = {
  data: {[key: string] : {
    date: number,
    feeInfo: {
      fast: string,
      normal: string,
      slow: string,
    }
  }}
}

export const btcLikeGetBytePrices = async (
  auth: AuthorizationSession,
  { network }: BtcLikeGetBytePricesRequest,
): Promise<BtcLikeGetBytePricesResponse['data']> => {

  const token = await auth.getPermissionToken();

  const response = await axios.get(
    `https://cloud.spatium.net/fee-info-btc-like/v1/static/fee-info-${network}.json`, {
      params: { network },
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response;
};

Расчет величины комиссии

Финальная величина оптимальной комиссии формируется из размера транзакции и средней стоимости одного байта. Также можно ввести общую сумму комиссии вручную, игнорируя и размер, и оценку.

const fee = (Number(prices.normal) * size).toFixed();

<!-- ### Общий запрос для оценки транзакции Также, существует единый эндпоинт для получения оценки транзакции, в котором выполняется:

  • Получение UTXO;
  • Оценка размера транзакции;
  • Получение информации о средней комиссии сети;
  • Расчет комиссий.
import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeGetTxEstimateRequest = {
  address: string;
  network?: 'livenet' | 'testnet';
  publicKey: string;
  type?: 'p2pkh' | 'p2wpkh';
  to: string;
  amount: string;
}
type BtcLikeGetTxEstimateResponse = {
  requestId: string;
  data: {
    utxo: UTXO[];
    size: number;
    fees: { normal: string; fast: string; slow: string };
  };
};

export const btcLikeGetTXEstimate = async  (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { address, network, publicKey, type, to, amount }: BtcLikeGetTxEstimateRequest,
): Promise<BtcLikeGetTxEstimateResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.post(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/prepare-transaction/estimate/${chain}`,
    {
      address,
      network,
      publicKey,
      type,
      to,
      amount,
    },
    {
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response.data;
};
``` -->

### Формирование хэша для подписи

Имея все предварительные данные, можно формировать хэш для подписи (в данном случае - набор хэшей).

```typescript
import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeGetHashRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  type?: 'p2pkh' | 'p2wpkh';
  utxo: { txid: string; vout: number; value: string }[];
  to: string;
  amount: string;
  fee: string;
};
type BtcLikeGetHashResponse = {
  requestId: string;
  data: {
    inputIndex: number;
    hash: string;
  }[];
};

export const btcLikeGetHash = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, publicKey, type, utxo, to, amount, fee }: BtcLikeGetHashRequest,
): Promise<BtcLikeGetHashResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.post(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/transaction/get-hash/${chain}`,
    {
      network,
      publicKey,
      type,
      utxo,
      to,
      amount,
      fee,
    },
    {
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response.data;
};

SMPC подпись хэша

import { randomBytes, uuid } from '@spatium/sdk';

export const signEcdsa = async (signerClient: SignerClient, secretId: string, syncSessionId: string, message: string): Promise<EcdsaSignature> => {
  const signSessionId = uuid(randomBytes);

  try {
    // Ожидаем установки соедения 
    await signerClient.connect(10 * 1000);

    return await signerClient.signEcdsaMessage(secretId, syncSessionId, signSessionId, message);
  } finally {
    await signerClient.disconnect();
  }
};

Формирование подписанной транзакции

Подпись (в данном случае - набор подписей) нужно прикрепить к транзакции, тем самым получив данные, готовые к отправке в блокчейн.

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeAttachSignatureRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  type?: 'p2pkh' | 'p2wpkh';
  utxo: { txid: string; vout: number; value: string }[];
  to: string;
  amount: string;
  fee: string;
  signatures: { inputIndex: number; signature: EcdsaSignature }[];
};
type BtcLikeAttachSignatureResponse = {
  requestId: string;
  data: { txdata: string };
};

export const btcLikeAttachSignature = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, publicKey, type, utxo, to, amount, fee, signatures }: BtcLikeAttachSignatureRequest,
): Promise<BtcLikeAttachSignatureResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.post(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/transaction/attach-signature/${chain}`,
    {
      publicKey,
      network,
      type,
      utxo,
      to,
      amount,
      fee,
      signatures,
    },
    {
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    },
  ).then((result) => result.data);

  return response.data;
};

Отправка транзакции в сеть

За отправку транзакции в блокчейн также отвечает Blockchain Connector Service

import axios from 'axios';
import { randomBytes, uuid } from '@spatium/sdk';

type BtcLikeSendTXRequest = {
  network?: 'livenet' | 'testnet';
  txdata: string;
};
type BtcLikeSendTXResponse = {
  requestId: string;
  data: { txid: string };
};

export const btcLikeSendTX = async (
  auth: AuthorizationSession,
  chain: BTCLikeChain,
  { network, txdata }: BtcLikeSendTXRequest,
): Promise<BtcLikeSendTXResponse> => {
  const token = await auth.getPermissionToken();

  const response = await axios.post(
    `https://cloud.spatium.net/blockchain-connector-btc-like/v1/api/transaction/send/${chain}`,
    {
      network,
      txdata,
    },
    {
      headers: {
        'request-id': uuid(randomBytes),
        'authorization': `Bearer ${token}`,
      },
    }
  ).then((result) => result.data);

  return response.data;
};

Полная процедура

import { AuthorizationSession, SignerClient } from '@spatium/signer-client';
import { randomBytes, uuid } from '@spatium/sdk';

const auth = new AuthorizationSession('https://cloud.spatium.net/authorization/v1', uuid(randomBytes), ['read', 'secret']);

// получение security токенов
const { data: { securityToken } } = await axios.post('https://cloud.spatium.net/authorization/v1/api/security-factor/credentials', {
    username: 'username', password: 'password',
  }, {
    headers: {
      'request-id': uuid(randomBytes),
    },
  }).then(({ data }) => data);

await auth.establish([securityToken]);

const btcLikeSignTransaction = async (auth: AuthorizationSession, signerClient: SignerClient, syncSessionId: string, 
  chain: BTCLikeChain, publicKey: string, to: string, amount: string) => {

  const { address } = await btcLikeGetAddress(auth, 'btc', { publicKey });

  const { utxo } = await btcLikeGetUTXO(auth, 'btc', { address });

  const { size } = await btcLikeGetTXSize(auth, 'btc', { publicKey, utxo, to, amount });

  const pricePerByte = await btcLikeGetBytePrices(auth, {});

  const fee = (Number(pricePerByte.btc?.feeInfo.normal) * size).toFixed();

  const { hashes } = await btcLikeGetHash(auth, 'btc', { publicKey, utxo, to, amount, fee });

  const signatures: { inputIndex: number; signature: EcdsaSignature }[] = [];

  for (const { inputIndex, hash } of hashes) {
    const signature = await signEcdsa(signerClient, syncSessionId, hash);

    signatures.push({ inputIndex, signature });
  }

  const txdata = await btcLikeAttachSignature(auth, 'btc', { publicKey, utxo, to, amount, fee, signatures });

  const txid = await btcLikeSendTX(auth, 'btc', { txdata });

  return txid;
}