GitHub Logo HIP-338: Signature and Wallet Providers

Author Daniel Akhterov
Discussions-To https://github.com/hashgraph/hedera-improvement-proposal/discussions/355
Status Accepted
Needs Council Approval No
Review period ends Tue, 22 Feb 2022 07:00:00 +0000
Type Standards Track
Category Application
Created 2022-02-08

Abstract

Ethereum like signature and wallet providers with the Hedera SDKs

Motivation

Cryptocurrency users are more used to Ethereum’s signature and wallet providers instead of direclty hardcoding private keys into their projects. Supporting third party signature and wallet provider would be better for security and user experience.

Specification

Provider

interface Provider {
    // Return the ledger ID for this network
    getLedgerId(): LedgerId?;

    // Return the full consensus network being used
    getNetwork(): {[key: string]: string};

    // Execute an `AccountBalanceQuery` for the particular account ID
    getAccountBalance(accountId: AccountId | string): Promise<AccountBalance>;

    // Execute an `AccountInfoQuery` for the particular account ID
    getAccountInfo(accountId: AccountId | string): Promise<AccountInfo>;

    // Execute an `AccountRecordsQuery` for the particular account ID
    getAccountRecords(accountId: AccountId | string): Promise<TransactionRecord[]>;
    
    // Execute an `TransactionReceiptQuery` for the particular transaction ID
    getTransactionReceipt(transactionId: TransactionId | string): Promise<TransactionReceipt>;

    // Execute multiple `TransactionReceiptQuery`'s until we get an erring status code, 
    // or a success status code
    waitForReceipt(response: TransactionResponse): Promise<TransactionReceipt>;

    // Execute an arbitrary request and return the response
    call<RequestT, ResponseT, OutputT>(request: Executable<RequestT, ResponseT, OutputT>): Promise<OutputT>;
}

A provider is a class which is ultimately connects to a Hedera network through a chain of services; this chain could be a single link, but by definition the chain could also be numerous link. The most important method on the provider interface is the call method which allows a user to submit any request and get the correct response for that request. For instance,

// Balance of node account ID 0.0.3
const balance = provider.call(new AccountBalanceQuery().setAccountId(AccountId.fromString("0.0.3")));

Signer

interface Signer {
    // Return the ledger ID for this network
    getLedgerId(): LedgerId?;
    
    // Return the account ID
    getAccountId(): AccountId;

    // [Optional] This method is not required to be implemented, but it is encouraged
    // Return the account key
    getAccountKey?(): Key;

    // Return the full consensus network being used
    getNetwork(): {[key: string]: string};

    // Sign a list of arbitrary messages
    sign(messages: Uint8Array[]): Promise<SignerSignature[]>;

    // Fetch the account balance for the signer's account ID
    getAccountBalance(): Promise<AccountBalance>;

    // Fetch the account info for the signer's account ID
    getAccountInfo(): Promise<AccountInfo>;

    // Fetch the account records for the signer's account ID
    getAccountRecords(): Promise<TransactionRecord[]>;

    // Sign a transaction, returning the signed transaction
    //
    // Note: This method is allowed to mutate the parameter being passed in
    // so the returned transaction is not guaranteed to be a new instance
    // of a transaction
    signTransaction<T extends Transaction>(transaction: T): Promise<T>;

    // Check whether all the required fields are set appropriately. Fields such
    // as the transaction ID's account ID should either be `null` or be equal
    // to the signer's account ID, and the node account IDs on the request
    // should exist within the signer's network.
    checkTransaction<T extends Transaction>(transaction: T): Promise<T>;

    // Populate the request with the required fields. The transaction ID
    // should be constructed from the signer's account ID, and the node account IDs
    // should be set using the signer's network.
    populateTransaction<T extends Transaction>(transaction: T): Promise<T>;

    // Execute an arbitrary request and return the response
    // Note: This is a wrapper around the `Provider.call()` method
    call<RequestT, ResponseT, OutputT>(request: Executable<RequestT, ResponseT, OutputT>): Promise<OutputT>;
}
  • A signer is responsible for signing requests

The main 3 classes are Signer, Provider, and Wallet. The Wallet extends the Signer. The Signer is responsible for Signing requests while the Provider is responsible for communication between an application and a Hedera network, but is not required to communicate direclty with a Hedera network. Note this means the Provider can for instance communicate with some third party service which finally communicates with a Hedera network. Not really sure how to write the distinction between Wallet and Signer though.

Wallet

A wallet is an specific implementation of a signer which contains the accounts public key, signing function, and account ID all in memory.

Example

Lets create a simple signature provider that communicates to a local REST service; we’ll name the two classes SimpleRestProvider and SimpleRestSigner. The following will be an incomplete example of a signature provider, but the point of it is to

SimpleRestProvider
/**
 * @implements {Provider}
 */
export class SimpleRestProvider {
    /**
     * @param {LedgerId?} ledgerId
     * @param { {[key: string]: string} } network
     * @param {string[]} mirrorNetwork
     */
    constructor(ledgerId, network, mirrorNetwork) {
        this.ledgerId = ledgerId;
        this.network = network;
        this.mirrorNetwork = mirrorNetwork;
    }

    /**
     * @returns {LedgerId?}
     */
    getLedgerId() {
        return this.ledgerId;
    }

    /**
     * @returns { {[key: string]: string} }
     */
    getNetwork() {
        return this.network;
    }

    /**
     * @returns {string[]}
     */
    getMirrorNetwork() {
        return this.mirrorNetwork;
    }

    /**
     * @param {AccountId | string} accountId
     * @returns {Promise<AccountBalance>}
     */
    getAccountBalance(accountId) {
        return this.call(new AccountBalanceQuery().setAccountId(accountId));
    }

    /**
     * @param {AccountId | string} accountId
     * @returns {Promise<AccountInfo>}
     */
    async getAccountInfo(accountId) {
        return this.call(new AccountInfoQuery().setAccountId(accountId));
    }

    /**
     * @param {AccountId | string} accountId
     * @returns {Promise<TransactionRecord[]>}
     */
    getAccountRecords(accountId) {
        return this.call(new AccountRecordsQuery().setAccountId(accountId));
    }

    /**
     * @param {TransactionId | string} transactionId
     * @returns {Promise<TransactionReceipt>}
     */
    getTransactionReceipt(transactionId) {
        return this.call(
            new TransactionReceiptQuery().setTransactionId(transactionId)
        );
    }

    /**
     * @param {TransactionResponse} response
     * @returns {Promise<TransactionReceipt>}
     */
    waitForReceipt(response) {
        return this.call(
            new TransactionReceiptQuery().setTransactionId(
                response.transactionId
            )
        );
    }

    /**
     * @template RequestT
     * @template ResponseT
     * @template OutputT
     * @param {Executable<RequestT, ResponseT, OutputT>} request
     * @returns {Promise<OutputT>}
     */
    async call(request) {
        /** @type { { response: string, error: string | undefined} | TransactionResponseJSON} */
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const response = (
            await instance.post("/request", {
                request: Buffer.from(request.toBytes()).toString("hex"),
            })
        ).data;

        if (Object.prototype.hasOwnProperty.call(response, "error")) {
            throw new Error(/** @type { { error: string } } */ (response).error);
        }

        if (Object.prototype.hasOwnProperty.call(response, "response")) {
            const inner = /** @type { {response: string} } */ (response).response;
            const bytes = Buffer.from(inner, "hex");

            // Since this is an example we're only implementing the essentials here
            // Complete signature providers would need to support all requests
            switch (request.constructor.name) {
                case "AccountBalanceQuery":
                    // @ts-ignore
                    return AccountBalance.fromBytes(bytes);
                case "AccountInfoQuery":
                    // @ts-ignore
                    return AccountInfo.fromBytes(bytes);
                case "TransactionReceipt":
                    // @ts-ignore
                    return TransactionReceipt.fromBytes(bytes);
                default:
                    throw new Error(
                        `unrecognzied request time ${request.constructor.name}`
                    );
            }
        } else {
            // @ts-ignore
            return TransactionResponse.fromJSON(response);
        }
    }
}

SimpleRestSigner

/**
 * @implements {Signer}
 */
export class SimpleRestSigner {
    /**
     * @param {AccountId} accountId
     * @param {PublicKey} publicKey
     * @param {Provider} provider
     */
    constructor(accountId, publicKey, provider) {
        this.accountId = accountId;
        this.publicKey = publicKey;
        this.provider = provider;
    }

    /**
     * @param {(AccountId | string)=} accountId
     * @returns {Promise<SimpleRestSigner>}
     */
    static async connect(accountId) {
        /**
         * @type { { accountId: string, publicKey: string, ledgerId: string, network: {[key: string]: string}, mirrorNetwork: string[], error: string | undefined } }
         */
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const response = (
            await instance.post("/login", {
                accountId: accountId != null ? accountId.toString() : null,
            })
        ).data;

        if (response.error != null) {
            throw new Error(response.error);
        }

        const id = AccountId.fromString(response.accountId);
        const publicKey = PublicKey.fromString(response.publicKey);
        const ledgerId = LedgerId.fromString(response.ledgerId);
        const provider = new SimpleRestProvider(
            ledgerId,
            response.network,
            response.mirrorNetwork
        );

        return new SimpleRestSigner(id, publicKey, provider);
    }

    /**
     * @returns {Provider=}
     */
    getProvider() {
        return this.provider;
    }

    /**
     * @abstract
     * @returns {AccountId}
     */
    getAccountId() {
        return this.accountId;
    }

    /**
     * @returns {Key}
     */
    getAccountKey() {
        return this.publicKey;
    }

    /**
     * @returns {LedgerId?}
     */
    getLedgerId() {
        return this.provider == null ? null : this.provider.getLedgerId();
    }

    /**
     * @abstract
     * @returns { {[key: string]: (string | AccountId)} }
     */
    getNetwork() {
        return this.provider == null ? {} : this.provider.getNetwork();
    }

    /**
     * @abstract
     * @returns {string[]}
     */
    getMirrorNetwork() {
        return this.provider == null ? [] : this.provider.getMirrorNetwork();
    }

    /**
     * @param {Uint8Array[]} messages
     * @returns {Promise<SignerSignature[]>}
     */
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    sign(messages) {
        return Promise.reject(new Error("not implemented"));
    }

    /**
     * @returns {Promise<AccountBalance>}
     */
    getAccountBalance() {
        return this.call(
            new AccountBalanceQuery().setAccountId(this.accountId)
        );
    }

    /**
     * @abstract
     * @returns {Promise<AccountInfo>}
     */
    getAccountInfo() {
        return this.call(new AccountInfoQuery().setAccountId(this.accountId));
    }

    /**
     * @abstract
     * @returns {Promise<TransactionRecord[]>}
     */
    getAccountRecords() {
        return this.call(
            new AccountRecordsQuery().setAccountId(this.accountId)
        );
    }

    /**
     * @template {Transaction} T
     * @param {T} transaction
     * @returns {Promise<T>}
     */
    async signTransaction(transaction) {
        /** @type {LocalProviderResponse} */
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const response = (
            await instance.post("/sign", {
                request: Buffer.from(transaction.toBytes()).toString("hex"),
            })
        ).data;

        if (Object.prototype.hasOwnProperty.call(response, "error")) {
            throw new Error(/** @type { { error: string } } */ (response).error);
        }

        return /** @type {T} */ (
            Transaction.fromBytes(Buffer.from(response.response, "hex"))
        );
    }

    /**
     * @template {Transaction} T
     * @param {T} transaction
     * @returns {Promise<T>}
     */
    checkTransaction(transaction) {
        const transactionId = transaction.transactionId;
        if (
            transactionId != null &&
            transactionId.accountId != null &&
            transactionId.accountId.compare(this.accountId) != 0
        ) {
            throw new Error(
                "transaction's ID constructed with a different account ID"
            );
        }

        if (this.provider == null) {
            return Promise.resolve(transaction);
        }

        const nodeAccountIds = (
            transaction.nodeAccountIds != null ? transaction.nodeAccountIds : []
        ).map((nodeAccountId) => nodeAccountId.toString());
        const network = Object.values(this.provider.getNetwork()).map(
            (nodeAccountId) => nodeAccountId.toString()
        );

        if (
            !nodeAccountIds.reduce(
                (previous, current) => previous && network.includes(current),
                true
            )
        ) {
            throw new Error(
                "Transaction already set node account IDs to values not within the current network"
            );
        }

        return Promise.resolve(transaction);
    }

    /**
     * @template {Transaction} T
     * @param {T} transaction
     * @returns {Promise<T>}
     */
    populateTransaction(transaction) {
        transaction.setTransactionId(TransactionId.generate(this.accountId));
        const network = Object.values(this.provider.getNetwork()).map(
            (nodeAccountId) =>
                typeof nodeAccountId === "string"
                    ? AccountId.fromString(nodeAccountId)
                    : new AccountId(nodeAccountId)
        );
        transaction.setNodeAccountIds(network);
        return Promise.resolve(transaction);
    }

    /**
     * @template RequestT
     * @template ResponseT
     * @template OutputT
     * @param {Executable<RequestT, ResponseT, OutputT>} request
     * @returns {Promise<OutputT>}
     */
    call(request) {
        if (this.provider == null) {
            throw new Error(
                "cannot send request with an wallet that doesn't contain a provider"
            );
        }

        return this.provider.call(request);
    }
}

Backwards Compatibility

This is 100% backwards compatible

Security Implications

WIP: hethers.js

How to Teach This

N/A

Reference Implementation

N/A

Rejected Ideas

N/A

Open Issues

N/A

References

Copyright/license

This document is licensed under the Apache License, Version 2.0 – see LICENSE or (https://www.apache.org/licenses/LICENSE-2.0)

Citation

Please cite this document as: