/**
* This module provides base classes for modeling interactions with
* keystores.
*
* It also defines several constants used throughout the API for
* categorizing messages.
*
* Integrations with new wallets should begin by creating a base class
* for that wallet by subclassing either `DirectKeystoreInteraction`
* or `IndirectKeystoreInteraction`.
*
* @module interaction
*/
import Bowser from "bowser";
import {signatureNoSighashType} from 'unchained-bitcoin';
/**
* Constant representing a keystore which is unsupported due to the
* kind of interaction or combination of paramters provided.
*
* @type {string}
*/
export const UNSUPPORTED = "unsupported";
/**
* Constant representing a keystore pending activation by the user.
*
* @type {string}
*/
export const PENDING = "pending";
/**
* Constant representing a keystore in active use.
*
* @type {string}
*/
export const ACTIVE = "active";
/**
* Constant for messages at the "info" level.
*
* @type {string}
*/
export const INFO = "info";
/**
* Constant for messages at the "warning" level.
*
* @type {string}
*/
export const WARNING = "warning";
/**
* Constant for messages at the "error" level.
*
* @type {string}
*/
export const ERROR = "error";
/**
* Enumeration of possible keystore states ([PENDING]{@link module:interaction.PENDING}|[ACTIVE]{@link module:interaction.ACTIVE}|[UNSUPPORTED]{@link module:interaction.UNSUPPORTED}).
*
* @constant
* @enum {string}
* @default
*
*/
export const STATES = [PENDING, ACTIVE, UNSUPPORTED];
/**
* Enumeration of possible message levels ([INFO]{@link module:interaction.INFO}|[WARNING]{@link module:interaction.WARNING}|[ERROR]{@link module:interaction.ERROR}).
*
* @constant
* @enum {string}
* @default
*
*/
export const LEVELS = [INFO, WARNING, ERROR];
/**
* Represents an image in a message returned by an interaction.
*
* @typedef module:interaction.MessageImage
* @type {Object}
* @property {string} label - a human-readable label for the image
* @property {string} mimeType - the MIME type of the image
* @property {string} data - base64-encoded image data
*/
/**
* Represents a message returned by an interaction.
*
* Message objects may have additional properties.
*
* @typedef module:interaction.Message
* @type {Object}
* @property {string} text - message text
* @property {string} code - a dot-separated message code, e.g. `device.connect` (*Optional for submessages*)
* @property {module:interaction.STATES} state - keystore state (*Optional for submessages*)
* @property {module:interaction.LEVELS} level - message level (*Optional for submessages*)
* @property {string} version - keystore version (can be a single version string or a range/spec) (*Optional*)
* @property {string} action - keystore action user is expected to take (*Optional*)
* @property {module:interaction.MessageImage} image - image for this message (*Optional*)
* @property {Message[]} messages - submessages (*Optional*)
*/
/**
* Abstract base class for all keystore interactions.
*
* Concrete subclasses will want to subclass either
* `DirectKeystoreInteraction` or `IndirectKeystoreInteraction`.
*
* Defines an API for subclasses to leverage and extend.
*
* - Subclasses should not have any internal state. External tools
* (UI frameworks such as React) will maintain state and pass it
* into the interaction in order to display properly.
*
* - Subclasses may override the default constructor in order to allow
* users to pass in parameters.
*
* - Subclasses should override the `messages` method to customize
* what messages are surfaced in applications at what state of the
* user interface.
*
* - Subclasses should not try to catch all errors, instead letting
* them bubble up the stack. This allows UI developers to deal with
* them as appropriate.
*
* @example
* import {KeystoreInteraction, PENDING, ACTIVE, INFO} from "unchained-wallets";
* class DoNothingInteraction extends KeystoreInteraction {
*
* constructor({param}) {
* super();
* this.param = param;
* }
*
* messages() {
* const messages = super.messages()
* messages.push({state: PENDING, level: INFO, text: `Interaction pending: ${this.param}` code: "pending"});
* messages.push({state: ACTIVE, level: INFO, text: `Interaction active: ${this.param}` code: "active"});
* return messages;
* }
*
* }
*
* // usage
* const interaction = new DoNothingInteraction({param: "foo"});
* console.log(interaction.messageTextFor({state: ACTIVE})); // "Interaction active: foo"
* console.log(interaction.messageTextFor({state: PENDING})); // "Interaction pending: foo"
*
*/
export class KeystoreInteraction {
/**
* Base constructor.
*
* Subclasses will often override this constructor to accept options.
*
* Just make sure to call `super()` if you do that!
*
* @constructor
*/
constructor() {
this.environment = Bowser.getParser(window.navigator.userAgent);
}
/**
* Subclasses can override this method to indicate they are not
* supported.
*
* This method has access to whatever options may have been passed
* in by the constructor as well as the ability to interact with
* `this.environment` to determine whether the functionality is
* supported. See the Bowser documentation for more details:
* https://github.com/lancedikson/bowser
*
* @returns {boolean} whether this interaction is supported
* @example
* isSupported() {
* return this.environment.satisfies({
* * declare browsers per OS
* windows: {
* "internet explorer": ">10",
* },
* macos: {
* safari: ">10.1"
* },
*
* * per platform (mobile, desktop or tablet)
* mobile: {
* safari: '>=9',
* 'android browser': '>3.10'
* },
*
* * or in general
* chrome: "~20.1.1432",
* firefox: ">31",
* opera: ">=22",
*
* * also supports equality operator
* chrome: "=20.1.1432", * will match particular build only
*
* * and loose-equality operator
* chrome: "~20", * will match any 20.* sub-version
* chrome: "~20.1" * will match any 20.1.* sub-version (20.1.19 as well as 20.1.12.42-alpha.1)
* });
* }
*/
isSupported() {
return true;
}
/**
* Return messages array for this interaction.
*
* The messages array is a (possibly empty) array of [`Message`]{@link module:interaction.Message} objects.
*
* Subclasses should override this method and add messages as
* needed. Make sure to call `super.messages()` to return an empty
* messages array for you to begin populating.
*
* @returns {module:interaction.Message[]} []
*/
messages() {
const messages = [];
return messages;
}
/**
* Return messages filtered by the given options.
*
* Multiple options can be given at once to filter along multiple
* dimensions.
*
* @param {object} options - options argument
* @param {string} options.state - must equal this keystore state
* @param {string} options.level - must equal this message level
* @param {string|regexp} options.code - code must match this regular expression
* @param {string|regexp} options.text - text must match this regular expression
* @param {string|regexp} options.version - version must match this regular expression
* @returns {module:interaction.Message[]} matching `Message` objects
* @example
* import {PENDING, ACTIVE} from "unchained-bitcoin";
* // Create any interaction instance
* interaction.messages().forEach(msg => console.log(msg));
* { code: "device.connect", state: "pending", level: "info", text: "Please plug in your device."}
* { code: "device.active", state: "active", level: "info", text: "Communicating with your device..."}
* { code: "device.active.warning", state: "active", level: "warning", text: "Your device will warn you about...", version: "2.x"}
* interaction.messagesFor({state: PENDING}).forEach(msg => console.log(msg));
* { code: "device.connect", state: "pending", level: "info", text: "Please plug in your device."}
* interaction.messagesFor({code: ACTIVE}).forEach(msg => console.log(msg));
* { code: "device.active", state: "active", level: "info", text: "Communicating with your device..."}
* { code: "device.active.warning", state: "active", level: "warning", text: "Your device will warn you about...", version: "2.x"}
* interaction.messagesFor({version: /^2/}).forEach(msg => console.log(msg));
* { code: "device.active", state: "active", level: "warning", text: "Your device will warn you about...", version: "2.x"}
*/
messagesFor({state, level, code, text, version}) {
return this.messages().filter((message) => {
if (state && message.state !== state) {
return false;
}
if (level && message.level !== level) {
return false;
}
if (code && !(message.code || '').match(code)) {
return false;
}
if (text && !(message.text || '').match(text)) {
return false;
}
if (version && !(message.version || '').match(version)) {
return false;
}
return true;
});
}
/**
* Return whether there are any messages matching the given options.
*
* @param {object} options - options argument
* @param {string} options.state - must equal this keystore state
* @param {string} options.level - must equal this message level
* @param {string|regexp} options.code - code must match this regular expression
* @param {string|regexp} options.text - text must match this regular expression
* @param {string|regexp} options.version - version must match this regular expression
* @returns {boolean} - whether any messages match the given filters
*/
hasMessagesFor({state, level, code, text, version}) {
return this.messagesFor({
state,
level,
code,
text,
version,
}).length > 0;
}
/**
* Return the first message matching the given options (or `null` if none is found).
*
* @param {object} options - options argument
* @param {string} options.state - must equal this keystore state
* @param {string} options.level - must equal this message level
* @param {string|regexp} options.code - code must match this regular expression
* @param {string|regexp} options.text - text must match this regular expression
* @param {string|regexp} options.version - version must match this regular expression
* @returns {module:interaction.Message|null} the first matching `Message` object (or `null` if none is found)
*/
messageFor({state, level, code, text, version}) {
const messages = this.messagesFor({
state,
level,
code,
text,
version,
});
if (messages.length > 0) {
return messages[0];
}
return null;
}
/**
* Retrieve the text of the first message matching the given options
* (or `null` if none is found).
*
* @param {object} options - options argument
* @param {string} options.state - must equal this keystore state
* @param {string} options.level - must equal this message level
* @param {string|regexp} options.code - code must match this regular expression
* @param {string|regexp} options.text - text must match this regular expression
* @param {string|regexp} options.version - version must match this regular expression
* @returns {string|null} the text of the first matching message (or `null` if none is found)
*/
messageTextFor({state, level, code, text, version}) {
const message = this.messageFor({
state,
level,
code,
text,
version,
});
return (message ? message.text : null);
}
}
/**
* Class used for describing an unsupported interaction.
*
* - Always returns `false` when the `isSupported` method is called.
*
* - Has a keystore state `unsupported` message at the `error` level.
*
* - Throws errors when attempting to call API methods such as `run`,
* `request`, and `parse`.
*
* @extends {module:interaction.KeystoreInteraction}
* @example
* import {UnsupportedInteraction} from "unchained-wallets";
* const interaction = new UnsupportedInteraction({text: "failure text", code: "fail"});
* console.log(interaction.isSupported()); // false
*
*/
export class UnsupportedInteraction extends KeystoreInteraction {
/**
* Accepts parameters to describe what is unsupported and why.
*
* The `text` should be human-readable. The `code` is for machines.
*
* @param {object} options - options argument
* @param {string} options.text - the text of the error message
* @param {string} options.code - the code of the error message
* @constructor
*/
constructor({text, code}) {
super();
this.text = text;
this.code = code;
}
/**
* By design, this method always returns false.
*
* @returns {false} Always.
*/
isSupported() {
return false;
}
/**
* Returns a single `error` level message at the `unsupported`
* state.
*
* @returns {module:interaction.Message[]} the messages for this class
*/
messages() {
const messages = super.messages();
messages.push({
state: UNSUPPORTED,
level: ERROR,
code: this.code,
text: this.text,
});
return messages;
}
/**
* Throws an error.
*
* @returns {void}
* @throws An error containing this `this.text`.
*
*/
async run() {
throw new Error(this.text);
}
/**
* Throws an error.
*
* @returns {void}
* @throws An error containing this `this.text`.
*
*/
request() {
throw new Error(this.text);
}
/**
* Throws an error.
*
* @returns {void}
* @throws An error containing this `this.text`.
*
*/
parse() {
throw new Error(this.text);
}
}
/**
* Base class for direct keystore interactions.
*
* Subclasses *must* implement a `run` method which communicates
* directly with the keystore. This method must be asynchronous
* (return a `Promise`) to accommodate delays with network, devices,
* &c.
*
* @example
* import {DirectKeystoreInteraction} from "unchained-wallets";
* class SimpleDirectInteraction extends DirectKeystoreInteraction { *
*
* constructor({param}) {
* super();
* this.param = param;
* }
*
* async run() {
* // Or do something complicated...
* return this.param;
* }
* }
*
* const interaction = new SimpleDirectInteraction({param: "foo"});
*
* const result = await interaction.run();
* console.log(result);
* // "foo"
*
*/
export class DirectKeystoreInteraction extends KeystoreInteraction {
/**
* Sets the `this.direct` property to `true`. This property can be
* utilized when introspecting on interaction classes..
*
* @constructor
*/
constructor() {
super();
this.direct = true;
}
/**
* Initiate the intended interaction and return a result.
*
* Subclasses *must* override this function. This function must
* always return a promise as it is designed to be called within an
* `await` block.
*
* @returns {Promise} Does the work of interacting with the keystore.
*
*/
async run() {
throw new Error("Override the `run` method in this interaction.");
}
/**
* Throws an error.
*
* @throws An error since this is a direct interaction.
* @returns {void}
*
*/
request() {
throw new Error("This interaction is direct and does not support a `request` method.");
}
/**
* Throws an error.
*
* @throws An error since this is a direct interaction.
* @returns {void}
*
*/
parse() {
throw new Error("This interaction is direct and does not support a `parse` method.");
}
signatureFormatter(inputSignature, format) {
// Ledger signatures include the SIGHASH byte (0x01) if signing for P2SH-P2WSH or P2WSH ...
// but NOT for P2SH ... This function should always return the signature with SIGHASH byte appended.
// While we don't anticipate Trezor making firmware changes to include SIGHASH bytes with signatures,
// We'll go ahead and make sure that we're not double adding the SIGHASH byte in case they do in the future.
const signatureWithSigHashByte = `${signatureNoSighashType(inputSignature)}01`;
return format === "buffer" ? Buffer.from(signatureWithSigHashByte, "hex") : signatureWithSigHashByte;
}
parseSignature(transactionSignature, format="hex") {
return (transactionSignature || []).map(inputSignature => this.signatureFormatter(inputSignature, format));
}
}
/**
* Base class for indirect keystore interactions.
*
* Subclasses *must* implement two methods: `request` and `parse`.
* Application code will pass the result of calling `request` to some
* external process (HTTP request, QR code, &c.) and pass the response
* to `parse` which should return a result.
*
* @example
* import {IndirectKeystoreInteraction} from "unchained-wallets";
* class SimpleIndirectInteraction extends IndirectKeystoreInteraction { *
*
* constructor({param}) {
* super();
* this.param = param;
* }
*
* request() {
* // Construct the data to be passed to the keystore...
* return this.param;
* }
*
* parse(response) {
* // Parse data returned from the keystore...
* return response;
* }
*
* }
*
* const interaction = new SimpleIndirectInteraction({param: "foo"});
*
* const request = interaction.request();
* const response = "bar"; // Or do something complicated with `request`
* const result = interaction.parse(response);
* console.log(result);
* // "bar"
*
*/
export class IndirectKeystoreInteraction extends KeystoreInteraction {
/**
* Sets the `this.indirect` property to `true`. This property can
* be utilized when introspecting on interaction classes.
*
* The `this.workflow` property is an array containing one or both
* of the strings `request` and/or `parse`. Their presence and
* order indicates to calling applications whether they are
* necessary and in which order they should be run.
*
* @constructor
*/
constructor() {
super();
this.indirect = true;
this.workflow = ["parse"];
}
/**
* Provide the request.
*
* Subclasses *may* override this function. It can return any kind
* of object. Strings, data for QR codes, HTTP requests, command
* lines, functions, &c. are all allowed. Whatever is appropriate
* for the interaction.
*
* @returns {Object} the request data
*
*/
request() {
throw new Error("Override the `request` method in this interaction.");
}
/**
* Parse the response into a result.
*
* Subclasses *must* override this function. It must accept an
* appropriate kind of `response` object and return the final result
* of this interaction.
*
* @param {Object} response - the raw response
* @returns {Object} the parsed response
*
*/
parse(response) {
throw new Error("Override the `parse` method in this interaction.");
}
/**
* Throws an error.
*
* @throws An error since this is an indirect interaction.
* @returns {void}
*
*/
async run() {
throw new Error("This interaction is indirect and does not support a `run` method.");
}
}