scval.js

import xdr from './xdr';

import { Address } from './address';
import { Contract } from './contract';
import { ScInt, XdrLargeInt, scValToBigInt } from './numbers/index';

/**
 * Attempts to convert native types into smart contract values
 * ({@link xdr.ScVal}).
 *
 * Provides conversions from smart contract XDR values ({@link xdr.ScVal}) to
 * native JavaScript types.
 *
 * The conversions are as follows:
 *
 *  - xdr.ScVal -> passthrough
 *  - null/undefined -> scvVoid
 *  - string -> scvString (a copy is made)
 *  - UintArray8 -> scvBytes (a copy is made)
 *  - boolean -> scvBool
 *
 *  - number/bigint -> the smallest possible XDR integer type that will fit the
 *    input value (if you want a specific type, use {@link ScInt})
 *
 *  - {@link Address} or {@link Contract} -> scvAddress (for contracts and
 *    public keys)
 *
 *  - Array<T> -> scvVec after attempting to convert each item of type `T` to an
 *    xdr.ScVal (recursively). note that all values must be the same type!
 *
 *  - object -> scvMap after attempting to convert each key and value to an
 *    xdr.ScVal (recursively). note that there is no restriction on types
 *    matching anywhere (unlike arrays)
 *
 * When passing an integer-like native value, you can also optionally specify a
 * type which will force a particular interpretation of that value.
 *
 * Note that not all type specifications are compatible with all `ScVal`s, e.g.
 * `toScVal("a string", {type: "i256"})` will throw.
 *
 * @param {any} val -       a native (or convertible) input value to wrap
 * @param {object} [opts] - an optional set of hints around the type of
 *    conversion you'd like to see
 * @param {string} [opts.type] - there is different behavior for different input
 *    types for `val`:
 *
 *     - when `val` is an integer-like type (i.e. number|bigint), this will be
 *       forwarded to {@link ScInt} or forced to be u32/i32.
 *
 *     - when `val` is an array type, this is forwarded to the recursion
 *
 *     - when `val` is an object type (key-value entries), this should be an
 *       object in which each key has a pair of types (to represent forced types
 *       for the key and the value), where `null` (or a missing entry) indicates
 *       the default interpretation(s) (refer to the examples, below)
 *
 *     - when `val` is a string type, this can be 'string' or 'symbol' to force
 *       a particular interpretation of `val`.
 *
 *     - when `val` is a bytes-like type, this can be 'string', 'symbol', or
 *       'bytes' to force a particular interpretation
 *
 *    As a simple example, `nativeToScVal("hello", {type: 'symbol'})` will
 *    return an `scvSymbol`, whereas without the type it would have been an
 *    `scvString`.
 *
 * @returns {xdr.ScVal} a wrapped, smart, XDR version of the input value
 * @throws {TypeError} if...
 *  - there are arrays with more than one type in them
 *  - there are values that do not have a sensible conversion (e.g. random XDR
 *    types, custom classes)
 *  - the type of the input object (or some inner value of said object) cannot
 *    be determined (via `typeof`)
 *  - the type you specified (via `opts.type`) is incompatible with the value
 *    you passed in (`val`), e.g. `nativeToScVal("a string", { type: 'i128' })`,
 *    though this does not apply for types that ignore `opts` (e.g. addresses).
 * @see scValToNative
 *
 * @example
 * nativeToScVal(1000);                   // gives ScValType === scvU64
 * nativeToScVal(1000n);                  // gives ScValType === scvU64
 * nativeToScVal(1n << 100n);             // gives ScValType === scvU128
 * nativeToScVal(1000, { type: 'u32' });  // gives ScValType === scvU32
 * nativeToScVal(1000, { type: 'i125' }); // gives ScValType === scvI256
 * nativeToScVal("a string");                     // gives ScValType === scvString
 * nativeToScVal("a string", { type: 'symbol' }); // gives scvSymbol
 * nativeToScVal(new Uint8Array(5));                      // scvBytes
 * nativeToScVal(new Uint8Array(5), { type: 'symbol' });  // scvSymbol
 * nativeToScVal(null); // scvVoid
 * nativeToScVal(true); // scvBool
 * nativeToScVal([1, 2, 3]);                    // gives scvVec with each element as scvU64
 * nativeToScVal([1, 2, 3], { type: 'i128' });  // scvVec<scvI128>
 * nativeToScVal({ 'hello': 1, 'world': [ true, false ] }, {
 *   type: {
 *     'hello': [ 'symbol', 'i128' ],
 *   }
 * })
 * // gives scvMap with entries: [
 * //     [ scvSymbol, scvI128 ],
 * //     [ scvString, scvArray<scvBool> ]
 * // ]
 *
 * @example
 * import {
 *   nativeToScVal,
 *   scValToNative,
 *   ScInt,
 *   xdr
 * } from '@stellar/stellar-base';
 *
 * let gigaMap = {
 *   bool: true,
 *   void: null,
 *   u32: xdr.ScVal.scvU32(1),
 *   i32: xdr.ScVal.scvI32(1),
 *   u64: 1n,
 *   i64: -1n,
 *   u128: new ScInt(1).toU128(),
 *   i128: new ScInt(1).toI128(),
 *   u256: new ScInt(1).toU256(),
 *   i256: new ScInt(1).toI256(),
 *   map: {
 *     arbitrary: 1n,
 *     nested: 'values',
 *     etc: false
 *   },
 *   vec: ['same', 'type', 'list'],
 * };
 *
 * // then, simply:
 * let scv = nativeToScVal(gigaMap);    // scv.switch() == xdr.ScValType.scvMap()
 *
 * // then...
 * someContract.call("method", scv);
 *
 * // Similarly, the inverse should work:
 * scValToNative(scv) == gigaMap;       // true
 */
export function nativeToScVal(val, opts = {}) {
  switch (typeof val) {
    case 'object': {
      if (val === null) {
        return xdr.ScVal.scvVoid();
      }

      if (val instanceof xdr.ScVal) {
        return val; // should we copy?
      }

      if (val instanceof Address) {
        return val.toScVal();
      }

      if (val instanceof Contract) {
        return val.address().toScVal();
      }

      if (val instanceof Uint8Array || Buffer.isBuffer(val)) {
        const copy = Uint8Array.from(val);
        switch (opts?.type ?? 'bytes') {
          case 'bytes':
            return xdr.ScVal.scvBytes(copy);
          case 'symbol':
            return xdr.ScVal.scvSymbol(copy);
          case 'string':
            return xdr.ScVal.scvString(copy);
          default:
            throw new TypeError(
              `invalid type (${opts.type}) specified for bytes-like value`
            );
        }
      }

      if (Array.isArray(val)) {
        if (val.length > 0 && val.some((v) => typeof v !== typeof val[0])) {
          throw new TypeError(
            `array values (${val}) must have the same type (types: ${val
              .map((v) => typeof v)
              .join(',')})`
          );
        }
        return xdr.ScVal.scvVec(val.map((v) => nativeToScVal(v, opts)));
      }

      if ((val.constructor?.name ?? '') !== 'Object') {
        throw new TypeError(
          `cannot interpret ${
            val.constructor?.name
          } value as ScVal (${JSON.stringify(val)})`
        );
      }

      return xdr.ScVal.scvMap(
        Object.entries(val)
          // The Soroban runtime expects maps to have their keys in sorted
          // order, so let's do that here as part of the conversion to prevent
          // confusing error messages on execution.
          .sort(([key1], [key2]) => key1.localeCompare(key2))
          .map(([k, v]) => {
            // the type can be specified with an entry for the key and the value,
            // e.g. val = { 'hello': 1 } and opts.type = { hello: [ 'symbol',
            // 'u128' ]} or you can use `null` for the default interpretation
            const [keyType, valType] = (opts?.type ?? {})[k] ?? [null, null];
            const keyOpts = keyType ? { type: keyType } : {};
            const valOpts = valType ? { type: valType } : {};

            return new xdr.ScMapEntry({
              key: nativeToScVal(k, keyOpts),
              val: nativeToScVal(v, valOpts)
            });
          })
      );
    }

    case 'number':
    case 'bigint':
      switch (opts?.type) {
        case 'u32':
          return xdr.ScVal.scvU32(val);

        case 'i32':
          return xdr.ScVal.scvI32(val);

        default:
          break;
      }

      return new ScInt(val, { type: opts?.type }).toScVal();

    case 'string': {
      const optType = opts?.type ?? 'string';
      switch (optType) {
        case 'string':
          return xdr.ScVal.scvString(val);

        case 'symbol':
          return xdr.ScVal.scvSymbol(val);

        case 'address':
          return new Address(val).toScVal();

        case 'u32':
          return xdr.ScVal.scvU32(parseInt(val, 10));

        case 'i32':
          return xdr.ScVal.scvI32(parseInt(val, 10));

        default:
          if (XdrLargeInt.isType(optType)) {
            return new XdrLargeInt(optType, val).toScVal();
          }

          throw new TypeError(
            `invalid type (${opts.type}) specified for string value`
          );
      }
    }

    case 'boolean':
      return xdr.ScVal.scvBool(val);

    case 'undefined':
      return xdr.ScVal.scvVoid();

    case 'function': // FIXME: Is this too helpful?
      return nativeToScVal(val());

    default:
      throw new TypeError(`failed to convert typeof ${typeof val} (${val})`);
  }
}

/**
 * Given a smart contract value, attempt to convert it to a native type.
 * Possible conversions include:
 *
 *  - void -> `null`
 *  - u32, i32 -> `number`
 *  - u64, i64, u128, i128, u256, i256 -> `bigint`
 *  - vec -> `Array` of any of the above (via recursion)
 *  - map -> key-value object of any of the above (via recursion)
 *  - bool -> `boolean`
 *  - bytes -> `Uint8Array`
 *  - symbol -> `string`
 *  - string -> `string` IF the underlying buffer can be decoded as ascii/utf8,
 *              `Uint8Array` of the raw contents in any error case
 *
 * If no viable conversion can be determined, this just "unwraps" the smart
 * value to return its underlying XDR value.
 *
 * @param {xdr.ScVal} scv - the input smart contract value
 *
 * @returns {any}
 * @see nativeToScVal
 */
export function scValToNative(scv) {
  // we use the verbose xdr.ScValType.<type>.value form here because it's faster
  // than string comparisons and the underlying constants never need to be
  // updated
  switch (scv.switch().value) {
    case xdr.ScValType.scvVoid().value:
      return null;

    // these can be converted to bigints directly
    case xdr.ScValType.scvU64().value:
    case xdr.ScValType.scvI64().value:
      return scv.value().toBigInt();

    // these can be parsed by internal abstractions note that this can also
    // handle the above two cases, but it's not as efficient (another
    // type-check, parsing, etc.)
    case xdr.ScValType.scvU128().value:
    case xdr.ScValType.scvI128().value:
    case xdr.ScValType.scvU256().value:
    case xdr.ScValType.scvI256().value:
      return scValToBigInt(scv);

    case xdr.ScValType.scvVec().value:
      return (scv.vec() ?? []).map(scValToNative);

    case xdr.ScValType.scvAddress().value:
      return Address.fromScVal(scv).toString();

    case xdr.ScValType.scvMap().value:
      return Object.fromEntries(
        (scv.map() ?? []).map((entry) => [
          scValToNative(entry.key()),
          scValToNative(entry.val())
        ])
      );

    // these return the primitive type directly
    case xdr.ScValType.scvBool().value:
    case xdr.ScValType.scvU32().value:
    case xdr.ScValType.scvI32().value:
    case xdr.ScValType.scvBytes().value:
      return scv.value();

    // Symbols are limited to [a-zA-Z0-9_]+, so we can safely make ascii strings
    //
    // Strings, however, are "presented" as strings and we treat them as such
    // (in other words, string = bytes with a hint that it's text). If the user
    // encoded non-printable bytes in their string value, that's on them.
    //
    // Note that we assume a utf8 encoding (ascii-compatible). For other
    // encodings, you should probably use bytes anyway. If it cannot be decoded,
    // the raw bytes are returned.
    case xdr.ScValType.scvSymbol().value:
    case xdr.ScValType.scvString().value: {
      const v = scv.value(); // string|Buffer
      if (Buffer.isBuffer(v) || ArrayBuffer.isView(v)) {
        try {
          return new TextDecoder().decode(v);
        } catch (e) {
          return new Uint8Array(v.buffer); // copy of bytes
        }
      }
      return v; // string already
    }

    // these can be converted to bigint
    case xdr.ScValType.scvTimepoint().value:
    case xdr.ScValType.scvDuration().value:
      return new xdr.Uint64(scv.value()).toBigInt();

    case xdr.ScValType.scvError().value:
      switch (scv.error().switch().value) {
        // Distinguish errors from the user contract.
        case xdr.ScErrorType.sceContract().value:
          return { type: 'contract', code: scv.error().contractCode() };
        default: {
          const err = scv.error();
          return {
            type: 'system',
            code: err.code().value,
            value: err.code().name
          };
        }
      }

    // in the fallthrough case, just return the underlying value directly
    default:
      return scv.value();
  }
}