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

Self-Custody Use Case (ETH-like)

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

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

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

  • auth - сессия авторизации (AuthorizationSession)
  • storage - (StorageDirver) отвечающий за хранение клиентского секрета
import { MemoryStorageDriver, Crypto } 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 Crypto(cache, storage)

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

func createSignerClient() -> SignerClient {
    let auth = AuthorizationSession(
        url: "https://cloud.spatium.net/authorization/v1",
        tokenId: UUID().uuidString,
        permissions: ["read", "secret"]
    )

    let clientCache = MemoryStorageDriver()
    let clientStorage = MemoryStorageDriver()

    let clientCrypto = SpatiumCrypto(cache: clientCache, storage: clientStorage)

    let clientProtocol = SignerClient(
        url: "https://cloud.spatium.net/signer/v1",
        auth: auth,
        crypto: clientCrypto,
        timeout: 10 * 1000
    )

    return clientProtocol
}

Важно! В данном примере на TypeScript в качестве хранилища используется 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();
  }
};
func ensureSecret(signerClient: SignerClient, secretId: String) async throws {
    if (await signerClient.crypto.checkSecret(secretId: secretId)) {
        return;
    }

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

Важно! На данном этапе разработки 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();
  }
};
func ensureEcdsaPublicKey(signerClient: SignerClient, secretId: String, syncSessionId: String, derivationCoin: UInt32, derivationAccount: UInt32) async throws -> String {

    let publicKey = try await getEcdsaPublicKey(driver: signerClient, secretId: secretId, syncSessionId: syncSessionId) 

    if !publicKey.isEmpty {
        return publicKey
    }

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

    let distributedEcdsaKey = try await syncDistributedEcdsaKey(
        driver: signerClient,
        secretId: secretId,
        syncSessionId: syncSessionId,
        curve: .secp256k1,
        derivationCoin: derivationCoin,
        derivationAccount: derivationAccount
    )

    return distributedEcdsaKey
}

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

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

type EthLikeGetAddressRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  prefix?: boolean;
};
type EthLikeGetAddressResponse = {
  requestId: string;
  data: { address: string };
}

export const ethLikeGetAddress = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, publicKey, prefix }: EthLikeGetAddressRequest,
): Promise<EthLikeGetAddressResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return response.data;
};
enum ETHLikeChain: String, Codable {
    case eth
    case etc
    case ftm
    case avax
    case matic
    case bsc
    case arb
    case op
}

struct EthLikeGetAddressRequest: Codable {
    enum Network: String, Codable {
        case livenet
        case testnet
    }

    let network: Network?
    let publicKey: String
    let prefix: Bool?
}

struct EthLikeGetAddressResponse: Decodable {
    let requestId: String
    let data: DataResponse
}

struct DataResponse: Decodable {
    let address: String
}

func ethLikeGetAddress(auth: AuthorizationSession, chain: ETHLikeChain, addressRequest: EthLikeGetAddressRequest) async throws -> String? {
    guard let url = URL(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/get-address/\(chain)") else { throw CustomError.urlError }

    let token = try await auth.getPermissionToken()

    let headers: [String: String] = [
        "request-id": UUID().uuidString,
        "authorization": "Bearer \(token)"
    ]

    var request = URLRequest(url: url)
    request.httpMethod = "POST"

    headers.forEach { (key, value) in
        request.addValue(value, forHTTPHeaderField: key)
    }
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")

    guard let body = try? JSONEncoder().encode(addressRequest) else {
        throw CustomError.configurationError
    }

    request.httpBody = body

    let (data,_) = try await URLSession.shared.data(for: request)
    guard let address = try? JSONDecoder().decode(EthLikeGetAddressResponse.self, from: data) else {
        throw CustomError.codingError
    }

    return address.data.address
}

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

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

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

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

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

type EthLikeGetAddressRequest = {
  network?: 'livenet' | 'testnet';
  address: string;
};
type EthLikeGetAddressResponse = {
  requestId: string;
  data: AddressInfo;
};

export const ethLikeGetAddressInfo = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, address }: EthLikeGetAddressRequest,
): Promise<EthLikeGetAddressResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return result.data;
};
struct AddressInfo: Codable {
  let address: String
  let chain: String
  let balances: [Balance]
  // let transactions: [Transaction]
}

struct Balance: Codable {
  let asset: Asset
  let balance: BalanceAmount
}

struct Asset: Codable {
  let chain: String
  let kind: String
}

struct BalanceAmount: Codable {
  let amount: String
  let decimals: Int
}

//    struct Transaction {
//      // properties
//    }

struct EthLikeGetAddressInfoRequest {
    enum Network: String, Codable {
        case livenet
        case testnet
    }
    let network: Network?
    let address: String
}

struct EthLikeGetAddressInfoResponse: Codable {
  let requestId: String
  let data: AddressInfo
}

func btcLikeGetAddressInfo(auth: AuthorizationSession,
                           chain: ETHLikeChain,
                           addressInfoRequest: EthLikeGetAddressInfoRequest) async throws -> AddressInfo {
    let token = try await auth.getPermissionToken()
    var url = URLComponents(string: "https://cloud.spatium.net/address-info-eth-like/v1/api/\(chain)")!
    let params = [
        "address": addressInfoRequest.address,
        "network": addressInfoRequest.network?.rawValue
    ]

    url.queryItems = params.map { (key, value) in
        URLQueryItem(name: key, value: value)
    }

    var request = URLRequest(url: url.url!)
    request.httpMethod = "GET"
    request.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")
    request.addValue("Bearer \(token)", forHTTPHeaderField: "authorization")

    let (data, _) = try await URLSession.shared.data(for: request)

    let result = try JSONDecoder().decode(EthLikeGetAddressInfoResponse.self, from: data)

    return result.data
}

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

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

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

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

Сбор nonce

Ethereum-подобные валюты для формирования транзакции требуют актуального на текущий момент nonce (одноразовый номер транзакции).

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

type EthLikeGetNonceRequest = {
  network?: 'livenet' | 'testnet';
  address: string;
};
type EthLikeGetNonceResponse = {
  requestId: string;
  data: { nonce: number };
};

export const ethLikeGetNonce = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, address }: EthLikeGetNonceRequest
): Promise<EthLikeGetNonceResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return response.data;
};
struct EthLikeGetNonceRequest {
    enum Network: String, Codable {
        case livenet
        case testnet
    }
    let network: Network?
    let address: String
}

struct EthLikeGetNonceResponse: Codable {
    var requestId: String
    var data: NonceResponse
}

struct NonceResponse: Codable {
    var nonce: Int
}

func ethLikeGetNonce(auth: AuthorizationSession, chain: ETHLikeChain, request: EthLikeGetNonceRequest) async throws -> Int {
    let token = try await auth.getPermissionToken()

    var url = URLComponents(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/prepare-transaction/nonce/\(chain)")!

    var params = [
        "address": request.address
    ]

    if let network = request.network {
        params["network"] = network.rawValue
    }

    url.queryItems = params.map { (key, value) in
        URLQueryItem(name: key, value: value)
    }

    let uuid = UUID().uuidString
    let headers = [
        "request-id": uuid,
        "authorization": "Bearer \(token)"
    ]

    var request = URLRequest(url: url.url!)
    request.httpMethod = "GET"

    headers.forEach { (key, value) in
        request.addValue(value, forHTTPHeaderField: key)
    }

    let (data, _) = try await URLSession.shared.data(for: request)
    let decodedResponse = try JSONDecoder().decode(EthLikeGetNonceResponse.self, from: data)

    return decodedResponse.data.nonce
}

Сбор данных перевода ERC-20 токена

Важно! Данный метод используется только при отправке ERC-20 токенов, а при отправке нативной валюты блокчейна этот метод не используется и в параметр data последующих запросов для работы с транзакцией отправляется пустая строка.

Для отправки ERC-20 токенов необходимо получить соответствующие данные трансфера, эти данные передаются в поле data последующих запросов для работы с транзакцией.

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

type GetErc20TransferDataRequest = {
  to: string;
  amount: string;
};
type GetErc20TransferDataResponse = {
  requestId: string;
  data: { erc20TransferData: string };
};

export const getErc20TransferData = async (
  auth: AuthorizationSession,
  { to, amount }: GetErc20TransferDataRequest
): Promise<GetErc20TransferDataResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.get(`https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/prepare-transaction/erc20-transfer-data`, {
    params: { to, amount },
    headers: {
      'request-id': uuid(randomBytes),
      'authorization': `Bearer ${token}`,
    },
  }).then((result) => result.data);

  return response.data;
};
struct GetErc20TransferDataRequest: Codable {
    let to: String
    let amount: String
}

struct GetErc20TransferDataResponse: Codable {
    let requestId: String
    let data: ERC20TransferData
}

struct ERC20TransferData: Codable {
    let erc20TransferData: String
}

func getErc20TransferData(auth: AuthorizationSession, request: GetErc20TransferDataRequest) async throws -> ERC20TransferData {
    let token = try await auth.getPermissionToken()

    let url = URL(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/prepare-transaction/erc20-transfer-data")!

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    guard let body = try? JSONEncoder().encode(request) else {
        throw CustomError.configurationError
    }
    urlRequest.httpBody = body

    let (data, _) = try await URLSession.shared.data(for: urlRequest)

    let response = try JSONDecoder().decode(GetErc20TransferDataResponse.self, from: data)

    return response.data
}

Сбор данных одобрения ERC-20 токена

Важно! Данный метод используется только при одобрении ERC-20 токенов, а при отправке нативной валюты блокчейна этот метод не используется и в параметр data последующих запросов для работы с транзакцией отправляется пустая строка.

Для одобрения ERC-20 токенов необходимо получить соответствующие данные одобрения, эти данные передаются в поле data последующих запросов для работы с транзакцией.

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

type GetErc20ApproveDataRequest = {
  to: string;
  amount: string;
};
type GetErc20ApproveDataResponse = {
  requestId: string;
  data: { erc20ApproveData: string };
};

export const getErc20ApproveData = async (
  auth: AuthorizationSession,
  { to, amount }: GetErc20ApproveDataRequest
): Promise<GetErc20ApproveDataResponse['data']> => {
  const token = await auth.getPermissionToken();

  const response = await axios.get(`https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/prepare-transaction/erc20-approve-data`, {
    params: { to, amount },
    headers: {
      'request-id': uuid(randomBytes),
      'authorization': `Bearer ${token}`,
    },
  }).then((result) => result.data);

  return response.data;
};
struct GetErc20ApproveDataRequest {
    let to: String
    let amount: String
}

struct GetErc20ApproveDataResponse: Decodable {
    let requestId: String
    let data: Erc20ApproveData
}

struct Erc20ApproveData: Decodable {
    let erc20ApproveData: String
}

func getErc20ApproveData(auth: AuthorizationSession,
                         request: GetErc20ApproveDataRequest) async throws -> Erc20ApproveData {

    let token = try await auth.getPermissionToken()

    var urlComponents = URLComponents(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/prepare-transaction/erc20-approve-data")!
    urlComponents.queryItems = [
        URLQueryItem(name: "to", value: request.to),
        URLQueryItem(name: "amount", value: request.amount)
    ]

    var urlRequest = URLRequest(url: urlComponents.url!)
    urlRequest.httpMethod = "GET"
    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let (data, _) = try await URLSession.shared.data(for: urlRequest)

    let response = try JSONDecoder().decode(GetErc20ApproveDataResponse.self, from: data)

    return response.data
}

Оценка объема работы для обработки транзакции

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

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

type EthLikeGetGasLimitRequest = {
  network?: 'livenet' | 'testnet';
  to: string;
  amount: string;
  data: string;
  nonce: number;
};
type EthLikeGetGasLimitResponse = {
  requestId: string;
  data: { gasLimit: string };
};

export const ethLikeGetGasLimit = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, to, amount, data, nonce }: EthLikeGetGasLimitRequest
): Promise<EthLikeGetGasLimitResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return response.data;
};
struct EthLikeGetGasLimitRequest: Encodable {
    enum Network: String, Codable {
        case livenet
        case testnet
    }
    let network: Network?
    let to: String
    let amount: String
    let data: String
    let nonce: Int
}

struct EthLikeGetGasLimitResponse: Decodable {
    let requestId: String
    let data: GasLimit
}

struct GasLimit: Decodable {
    let gasLimit: String
}

func ethLikeGetGasLimit(auth: AuthorizationSession,
                        chain: ETHLikeChain,
                        request: EthLikeGetGasLimitRequest) async throws -> GasLimit {

    let token = try await auth.getPermissionToken()

    let url = URL(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/prepare-transaction/gas-limit/\(chain.rawValue)")!

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let body = try JSONEncoder().encode(request)
    urlRequest.httpBody = body

    let (data, _) = try await URLSession.shared.data(for: urlRequest)

    let response = try JSONDecoder().decode(EthLikeGetGasLimitResponse.self, from: data)

    return response.data
}

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

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

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

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

  const token = await auth.getPermissionToken();

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

  return response;
};
enum Network: String, Codable {
    case livenet
    case testnet
}

struct GasPriceInfo: Decodable {
    let date: Int
    let feeInfo: FeeDetails
}

struct FeeDetails: Decodable {
    let fast: String
    let normal: String
    let slow: String
}

func ethLikeGetGasPrices(auth: AuthorizationSession,
                         network: Network?) async throws -> [String: GasPriceInfo] {

    let token = try await auth.getPermissionToken()

    let url = URL(string: "https://cloud.spatium.net/fee-info-eth-like/v1/static/fee-info-\(network?.rawValue ?? "").json")!

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "GET"
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let (data, _) = try await URLSession.shared.data(for: urlRequest)

    let response = try JSONDecoder().decode([String: GasPriceInfo].self, from: data)

    return response
}

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

Финальная величина оптимальной комиссии формируется из объема работы, выполняемой валидатором для обработки транзакции, и средней стоимости одной единицы работы.

const fee = (Number(gasPrices.normal) * gasLimit).toFixed();
let fee = (Double(gasPrices.normal)! * Double(gasLimit)!).rounded().description

Однако в рамках API расчет данной величины не требуется, так как параметры gasLimit и gasPrice для работы с транзакциями передаются как есть напрямую.

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

Имея все предварительные данные, можно формировать хэш для подписи.

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

type EthLikeGetHashRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  to: string;
  amount: string;
  data: string;
  nonce: number;
  gasLimit: string;
  gasPrice: string;
};
type EthLikeGetHashResponse = {
  requestId: string;
  data: { hash: string };
};

export const ethLikeGetHash = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, publicKey, to, amount, data, nonce, gasLimit, gasPrice }: EthLikeGetHashRequest,
): Promise<EthLikeGetHashResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return response.data;
};
struct EthLikeGetHashRequest: Encodable {
    enum Network: String, Codable {
        case livenet
        case testnet
    }

    let network: Network?
    let publicKey: String
    let to: String
    let amount: String
    let data: String
    let nonce: Int
    let gasLimit: String
    let gasPrice: String
}

struct EthLikeGetHashResponse: Decodable {
    let requestId: String
    let data: HashData
}

struct HashData: Decodable {
    let hash: String
}

func ethLikeGetHash(auth: AuthorizationSession,
                    chain: ETHLikeChain,
                    request: EthLikeGetHashRequest) async throws -> HashData {

    let token = try await auth.getPermissionToken()

    let url = URL(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/transaction/get-hash/\(chain.rawValue)")!

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let body = try JSONEncoder().encode(request)
    urlRequest.httpBody = body

    let (data, _) = try await URLSession.shared.data(for: urlRequest)

    let response = try JSONDecoder().decode(EthLikeGetHashResponse.self, from: data)

    return response.data
}

Получение токена подписи

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

export const getSigningToken = async (message: string): Promise<string> => {

  const { data: { permissionToken: { permissionToken: signingToken } } } = await axios.post('https://cloud.spatium.netauthorization/v1/api/permission/issue-by-key', {
    tokenId: uuid(randomBytes),
    permissions: ['sign'],
    payload: message,
    merchantKey: 'your-merchant-key',
  }).then(({ data }) => data);

  return signingToken;
};
//    struct PermissionRefreshToken : Codable {
//        let permissionRefreshToken : String?
//        let issuer : String?
//        let expiresAt : Int?
//    }

struct PermissionToken : Codable {
    let permissionToken : String
    let issuer : String?
    let expiresAt : Int?
}

struct TokenModel : Codable {
    //        let permissionRefreshToken : PermissionRefreshToken?
    let permissionToken : PermissionToken
}

struct IssueKeyResponse : Codable {
    let requestId : String
    let data : TokenModel
}

func getSigningToken(message: String) async throws -> String {
    let tokenId = UUID().uuidString

    let url = URL(string: "https://cloud.spatium.net/authorization/v1/api/permission/issue-by-key")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"

    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    request.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let body: [String: Any] = [
        "tokenId": tokenId,
        "permissions": ["sign"],
        "payload": message,
        "merchantKey": Constants.merchantKey //"your-merchant-key"
    ]

    request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])

    let (data, _) = try await URLSession.shared.data(for: request)

    guard let tokenModel = try? JSONDecoder().decode(IssueKeyResponse.self, from: data) else {
        throw CustomError.codingError
    }

    return tokenModel.data.permissionToken.permissionToken
}

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

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

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

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

    return await signerClient.signEcdsaMessage(secretId, syncSessionId, signSessionId, message, signingToken);
  } finally {
    await signerClient.disconnect();
  }
};
func signEcdsa(signerClient: SignerClient, secretId: String, syncSessionId: String, message: String, signingToken: String) async throws -> EcdsaSignature {

    let signSessionId = UUID().uuidString

    // Wait for the actual connection to be established
    try await signerClient.connect(timeout: 10 * 1000)

    defer {
        signerClient.disconnect()
    }

    do {
        let signature = try await signerClient.signEcdsaMessage(secretId: secretId,
                                                                syncSessionId: syncSessionId,
                                                                signSessionId: signSessionId,
                                                                message: message,
                                                                signatureToken: signingToken)
        return signature
    } catch {
        throw error
    }
}

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

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

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

type EthLikeAttachSignatureRequest = {
  network?: 'livenet' | 'testnet';
  publicKey: string;
  to: string;
  amount: string;
  data: string;
  nonce: number;
  gasLimit: string;
  gasPrice: string;
  signature: EcdsaSignature;
};
type EthLikeAttachSignatureResponse = {
  requestId: string;
  data: { txdata: string };
};

export const ethLikeAttachSignature = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, publicKey, to, amount, data, nonce, gasLimit, gasPrice, signature }: EthLikeAttachSignatureRequest,
): Promise<EthLikeAttachSignatureResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return response.data;
};
struct EthLikeAttachSignatureRequest: Encodable {
    enum Network: String, Codable {
        case livenet
        case testnet
    }

    let network: Network?
    let publicKey: String
    let to: String
    let amount: String
    let data: String
    let nonce: Int
    let gasLimit: String
    let gasPrice: String
    let signature: EcdsaSignature
}

struct EthLikeAttachSignatureResponse: Decodable {
    let requestId: String
    let data: TxData
}

struct TxData: Decodable {
    let txdata: String
}

func ethLikeAttachSignature(auth: AuthorizationSession,
                            chain: ETHLikeChain,
                            request: EthLikeAttachSignatureRequest) async throws -> TxData {

    let token = try await auth.getPermissionToken()

    let url = URL(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/transaction/attach-signature/\(chain.rawValue)")!

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let body = try JSONEncoder().encode(request)
    urlRequest.httpBody = body

    let (data, _) = try await URLSession.shared.data(for: urlRequest)

    let response = try JSONDecoder().decode(EthLikeAttachSignatureResponse.self, from: data)

    return response.data
}

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

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

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

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

export const ethLikeSendTX = async (
  auth: AuthorizationSession,
  chain: ETHLikeChain,
  { network, txdata }: EthLikeSendTXRequest,
): Promise<EthLikeSendTXResponse['data']> => {
  const token = await auth.getPermissionToken();

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

  return response.data;
};
struct EthLikeSendTXRequest: Encodable {
    enum Network: String, Codable {
        case livenet
        case testnet
    }

    let network: Network?
    let txdata: String
}

struct EthLikeSendTXResponse: Decodable {
    let requestId: String
    let data: TXID
}

struct TXID: Decodable {
    let txid: String
}

func ethLikeSendTX(auth: AuthorizationSession,
                   chain: ETHLikeChain,
                   request: EthLikeSendTXRequest) async throws -> TXID {

    let token = try await auth.getPermissionToken()

    let url = URL(string: "https://cloud.spatium.net/blockchain-connector-eth-like/v1/api/transaction/send/\(chain.rawValue)")!

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")

    let body = try JSONEncoder().encode(request)
    urlRequest.httpBody = body

    let (data, _) = try await URLSession.shared.data(for: urlRequest)
    print("ethLikeSendTX response: \(String(data:data, encoding: .utf8)!)")

    let response = try JSONDecoder().decode(EthLikeSendTXResponse.self, from: 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 ethLikeSignTransaction = async (auth: AuthorizationSession, signerClient: SignerClient, syncSessionId: string, 
  chain: ETHLikeChain, publicKey: string, to: string, amount: string) => {
  const { nonce } = await ethLikeGetNonce(auth, 'eth', { address });

  // получение данных трансфера erc20
  // const { erc20TransferData } = await erc20GetTransfer(auth, { to, amount });

  // получение данных одобрения erc20
  // const { erc20ApproveData } = await erc20GetApprove(auth, { to, amount });

  const { gasLimit } = await ethLikeGetGasLimit(auth, 'eth', { to, amount, data: '', nonce });

  const pricePerByte = await getFee(auth, { network });
  const gasPrices = pricePerByte.eth?.feeInfo

  const { hash } = await ethLikeGetHash(auth, 'eth', { publicKey, to, amount, data: '', nonce, gasLimit, gasPrice: gasPrices.normal });

  const signingToken = await getSigningToken(hash)

  const signature = await signEcdsa(signerClient, syncSessionId, hash, signingToken);

  const { txdata } = await ethLikeAttachSignature(auth, 'eth', { publicKey, to, amount, data: '', nonce, gasLimit, gasPrice: gasPrices.normal, signature });

  const { txid } = await ethLikeSendTX(auth, 'eth', { txdata });

  return txid;
}
struct SecurityTokenResponseData : Codable, Equatable {
    let securityToken: String
}

struct SecurityTokenResponse : Codable, Equatable {
    let requestId: String
    let data: SecurityTokenResponseData
}

let auth = AuthorizationSession(url: "https://cloud.spatium.net/authorization/v1", tokenId: UUID().uuidString, permissions: ["read", "secret"])

// get security tokens
let url = URL(string: "https://cloud.spatium.net/authorization/v1/api/security-factor/credentials")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue(UUID().uuidString, forHTTPHeaderField: "request-id")
guard let body = try? JSONEncoder().encode(["username": "username", "password": "password"])  else {
    throw CustomError.configurationError
}
request.httpBody = body

let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(SecurityTokenResponse.self, from: data)

try await auth.establish([response.data.securityToken])

func ethLikeSignTransaction(
    auth: AuthorizationSession,
    secretId: String,
    signerClient: SignerClient,
    syncSessionId: String,
    chain: ETHLikeChain,
    publicKey: String,
    to: String,
    amount: String) async throws -> String {
    guard let address = try await ethLikeGetAddress(
        auth: auth,
        chain: .eth,
        addressRequest: EthLikeGetAddressRequest(
            network: .testnet,
            publicKey: publicKey,
            prefix: false
        )
    ) else {
        throw CustomError.configurationError
    }

    let nonce = try await ethLikeGetNonce(
        auth: auth,
        chain: .eth,
        request: EthLikeGetNonceRequest(
            network: .testnet,
            address: address
        )
    )

    let gasLimitResult = try await ethLikeGetGasLimit(
        auth: auth,
        chain: .eth,
        request: EthLikeGetGasLimitRequest(
            network: .testnet,
            to: to,
            amount: amount,
            data: "",
            nonce: nonce
        )
    )

    let gasLimit = gasLimitResult.gasLimit

    let gasPricesResult = try await ethLikeGetGasPrices(auth: auth, network: .testnet)
    let gasPrice = gasPricesResult["eth"]?.feeInfo.normal ?? "0"

    let hashResult = try await ethLikeGetHash(
        auth: auth,
        chain: .eth,
        request: EthLikeGetHashRequest(
            network: .testnet,
            publicKey: publicKey,
            to: to,
            amount: amount,
            data: "", 
            nonce: nonce, 
            gasLimit: gasLimit,
            gasPrice: gasPrice
        )
    )

    let hash = hashResult.hash

    let signingToken = try await getSigningToken(message: hash)

    let signature = try await signEcdsa(
        signerClient: signerClient,
        secretId: secretId,
        syncSessionId: syncSessionId,
        message: hash,
        signingToken: signingToken
    )

    let txResult = try await ethLikeAttachSignature(
        auth: auth,
        chain: .eth,
        request: EthLikeAttachSignatureRequest(
            network: .testnet,
            publicKey: publicKey,
            to: to,
            amount: amount,
            data: "",
            nonce: nonce,
            gasLimit: gasLimit,
            gasPrice: gasPrice,
            signature: signature
        )
    )

    let txdata = txResult.txdata

    let txidResult = try await ethLikeSendTX(
        auth: auth,
        chain: .eth,
        request: EthLikeSendTXRequest(
            network: .testnet,
            txdata: txdata
        )
    )

    let txid = txidResult.txid

    return txid
}