src/sep7-utils.js

"use strict"
/**
 * Sep-0007 protocol utilities.
 *
 * The methods listed in the documentation are browser-only.
 *
 * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md
 *
 * @exports sep7Utils
 */
const sep7Utils = exports

const Buffer = require("@cosmic-plus/base/es5/buffer")
const env = require("@cosmic-plus/jsutils/es5/env")
const StellarSdk = require("@cosmic-plus/base/es5/stellar-sdk")
const { timeout } = require("@cosmic-plus/jsutils/es5/misc")

const check = require("./check")
const convert = require("./convert")
const decode = require("./decode")
const parse = require("./parse")

/* Utils */

/**
 * Proposes the user to register **handler** as her default SEP-0007 link
 * handler. SEP-0007 requests will hit `{handler}?sep7={sep7Request}`.
 *
 * Note that this makes use of a non-standard feature. Compatibility can be
 * checked using `sep7Utils.isWebHandlerSupported()`.
 *
 * [List of compatible browsers](https://caniuse.com/#feat=registerprotocolhandler).
 *
 * @example
 * cosmicLib.sep7Utils.registerWebHandler(
 *  "https://cosmic.link/",
 *  "Cosmic.link"
 * )
 *
 * @param handler {String} URL of the SEP-0007 web handler.
 * @param description {String} A brief description of the service.
 */
sep7Utils.registerWebHandler = function (handler, description) {
  if (!env.isBrowser) throw new Error("This is a browser-only function.")

  if (navigator.registerProtocolHandler) {
    navigator.registerProtocolHandler(
      "web+stellar",
      `${handler}?sep7=%s`,
      description
    )
  } else {
    throw new Error("This browser can't register a SEP-0007 web handler.")
  }
}

/**
 * Returns whether or not the browser can register a SEP-0007 web handler.
 *
 * @example
 * if (cosmicLib.sep7Utils.isWebHandlerSupported()) {
 *    registerSep7HandlerButton.show()
 * }
 *
 * @returns {Boolean}
 */
sep7Utils.isWebHandlerSupported = function () {
  return env.isBrowser && !!navigator.registerProtocolHandler
}

/* Parsing */

/**
 * Initialize cosmicLink using `sep7Request`.
 * @private
 */
sep7Utils.parseRequest = function (cosmicLink, sep7Request, options) {
  parse.page(cosmicLink, sep7Request)

  const query = convert.uriToQuery(cosmicLink, sep7Request)
  const sep7 = decodeURIComponent(query.replace(/^\?(req|sep7)=/, ""))
  return sep7Utils.parseLink(cosmicLink, sep7, options)
}

/**
 * Initialize cosmicLink using `sep7`.
 * @private
 */
sep7Utils.parseLink = function (cosmicLink, sep7, options = {}) {
  cosmicLink._sep7 = sep7
  if (!options.network) options.network = "public"
  if (sep7.substr(12, 4) === "pay?") {
    cosmicLink.extra.type = "pay"
    return sep7Utils.parsePayLink(cosmicLink, sep7, options)
  } else if (sep7.substr(12, 3) === "tx?") {
    cosmicLink.extra.type = "tx"
    return sep7Utils.parseTxLink(cosmicLink, sep7, options)
  } else {
    throw new Error("Invalid SEP-0007 link.")
  }
}

/**
 * Initialize cosmicLink using `sep7.xdr`.
 * @private
 */
sep7Utils.parseTxLink = function (cosmicLink, sep7, options = {}) {
  const query = convert.uriToQuery(cosmicLink, sep7)
  const params = query.substr(1).split("&")
  let xdr

  params.forEach((entry) => {
    const field = entry.replace(/=.*$/, "")
    const value = entry.substr(field.length + 1)

    switch (field) {
    case "xdr":
      xdr = decodeURIComponent(value)
      break
    case "pubkey":
      check.address(cosmicLink, value)
      cosmicLink.extra.pubkey = value
      break
    default:
      sep7Utils.parseLinkCommons(cosmicLink, "xdr", field, value, options)
    }
  })

  if (!xdr) throw new Error("Missing XDR parameter")
  options.stripNeutralSequence = true
  return { type: "xdr", value: xdr, options }
}

/**
 * Initialize cosmicLink using `sep7.pay`.
 * @private
 */
sep7Utils.parsePayLink = function (cosmicLink, sep7, options = {}) {
  const query = convert.uriToQuery(cosmicLink, sep7)
  const params = query.substr(1).split("&")

  const odesc = { type: "payment" },
    asset = {},
    memo = {}
  const tdesc = {
    network: options.network,
    operations: [odesc]
  }

  // Parse
  params.forEach((entry) => {
    const field = entry.replace(/=.*$/, "")
    const value = entry.substr(field.length + 1)

    switch (field) {
    case "destination":
    case "amount":
      odesc[field] = decode.field(cosmicLink, field, value)
      break
    case "asset_code":
      asset.code = decodeURIComponent(value)
      break
    case "asset_issuer":
      asset.issuer = decodeURIComponent(value)
      break
    case "memo_type":
      memo.type = value.split("_")[1].toLowerCase()
      break
    case "memo":
      memo.value = decode.string(cosmicLink, value)
      break
    default:
      sep7Utils.parseLinkCommons(cosmicLink, "pay", field, value, options)
    }
  })

  // Convert
  if (memo.type || memo.value) {
    if (memo.type === "hash" || memo.type === "return") {
      memo.value = Buffer.from(memo.value, "base64").toString("hex")
    }
    tdesc.memo = memo
  }
  if (asset.code || asset.issuer) odesc.asset = asset

  if (!odesc.destination) throw new Error("Missing parameter: destination")
  return { type: "tdesc", value: tdesc, options }
}

sep7Utils.parseLinkCommons = function (
  cosmicLink,
  mode,
  field,
  value,
  options
) {
  switch (field) {
  case "network_passphrase":
    options.network = decode.network(cosmicLink, value)
    break
  case "callback":
    value = decode.url(cosmicLink, value)
    if (value.substr(0, 4) !== "url:") {
      throw new Error("Invalid callback: " + value)
    }
    options.callback = value.substr(4)
    break
  case "origin_domain":
    cosmicLink.extra.originDomain = sep7Utils.verifySignature(
      cosmicLink,
      value
    )
    break
  case "signature":
    cosmicLink.extra.signature = decodeURIComponent(value)
    break
  case "msg":
    cosmicLink.extra.msg = decode.string(cosmicLink, value)
    break
  default:
    throw new Error(`Invalid SEP-0007 ${mode} field: ` + field)
  }
}

/* Signing */

sep7Utils.signLink = function (cosmicLink, domain, keypair) {
  if (!cosmicLink.locker) throw new Error("cosmicLink is not locked.")

  cosmicLink.extra.originDomain = domain
  delete cosmicLink.extra.signature
  delete cosmicLink._sep7

  const link = cosmicLink.sep7
  const payload = sep7Utils.makePayload(link)
  cosmicLink.extra.signature = keypair.sign(payload).toString("base64")
  delete cosmicLink._sep7
}

sep7Utils.verifySignature = async function (cosmicLink, domain) {
  const link = cosmicLink.sep7.replace(/&signature=.*/, "")

  // Let parser parse signature.
  await timeout(1)
  const signature = cosmicLink.extra.signature

  if (!signature) {
    throw new Error(`No signature attached for domain: ${domain}`)
  }

  const payload = sep7Utils.makePayload(link)
  const keypair = await sep7Utils.getDomainKeypair(domain)

  if (keypair.verify(payload, Buffer.from(signature, "base64"))) {
    return domain
  } else {
    throw new Error(`Invalid signature for domain: ${domain}`)
  }
}

sep7Utils.getDomainKeypair = async function (domain) {
  const toml = await StellarSdk.StellarTomlResolver.resolve(domain)
  const signingKey = toml.URI_REQUEST_SIGNING_KEY

  if (!signingKey) {
    throw new Error(`Can't find signing key for domain: ${domain}`)
  }

  return StellarSdk.Keypair.fromPublicKey(signingKey)
}

sep7Utils.makePayload = function (link) {
  return Buffer.concat([
    Buffer.alloc(35),
    Buffer.alloc(1, 4),
    Buffer.from(`stellar.sep.7 - URI Scheme${link}`)
  ])
}