HIP-338: Signature and Wallet Providers Source

AuthorDaniel Akhterov
Discussions-Tohttps://github.com/hashgraph/hedera-improvement-proposal/discussions/355
StatusAccepted
Needs Council ApprovalNo
Review period endsTue, 22 Feb 2022 07:00:00 +0000
TypeStandards Track
CategoryApplication
Created2022-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

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.

abstract class Signer

Methods

async sign ( messages: List < bytes > ): List < List < SignerSignature > >

Sign a list of messages

NOTE: Each element in the outer list of the result is all the signatures for the message at the same index.


async signTransaction ( transaction: Transaction ): Transaction

Signs the transaction

NOTE: Use Transaction.getSignatures() to see the actual signatures. message at the same index.


async sendRequest ( request: Executable ): Response

Sign and send a request using the wallet


async checkTransaction ( transaction: Transaction ): Transaction

Determines if all the properties required are set and sets the transaction ID. If the transaction ID was already set it checks if the account ID of it is the same as the users.


async populateTransaction ( transaction: Transaction ): Transaction

Sets the transaction ID of the transaction to the current account ID of the signer.


getLedgerId (): LedgerId

Return the ledger ID


getAccountId (): AccountId

Return the account ID associated with this signer


async getAccountBalance (): AccountBalance

Fetch the account’s balance


async getAccountInfo (): AccountInfo

Fetch the account’s info


async getTransactionRecords (): List < TransactionRecord >

Fetch the last transaction records for this account using TransactionRecordQuery


async getTransactionRecords (): List < TransactionRecord >

Fetch the last transaction records for this account using TransactionRecordQuery


abstract class Provider

Methods

getLedgerId (): LedgerId

Return the ID of the current network


getNetwork (): Map < string, AccountId >

Return the entire network map for the current network


getMirrorNetwork (): List < string >

Return the mirror network


async getAccountBalance ( accountId: AccountId ): AccountBalance

Get the balance for an account


async getAccountInfo ( accountId: AccountId ): AccountInfo

Get the info for an account


async getTransactionReceipt ( transactionId: TransactionId ): TransacitonReceipt

Get a receipt for a transaction ID


async sendRequest ( request: Executable ): Response

Sign and send a request using the wallet


async waitForReceipt ( response: TransactionResponse ): TransactionReceipt

Wait for the receipt for a transaction response

NOTE: This is different from getTransactionReceipt() this method requires a nodeId which is set inside TransactionResponse and as a result should not be able to fail with RECEIPT_NOT_FOUND


abstract class Wallet extends Signer

Static Methods

withPrivateKey ( privateKey: PrivateKey ): Wallet

Create a wallet using a private key


Methods

getProvider (): Provider

Return the provider


getAccountKey (): Key

Return the public key associated with this wallet.


async createRandomED25519 (): Wallet

Creates a wallet with a new ED25519 key

NOTE: This would create an alias key account on Hedera


async createRandomECDSA (): Wallet

Creates a wallet with a new ECDSA key

NOTE: This would create an alias key account on Hedera


class SignerSignature

Fields

publicKey: PublicKey

The public key that signed this request


signature: bytes

The signature for the message


accountId: AccountId

The account ID associated with the public key which signed the transaction

NOTE: This account ID may be repeated multiple times if an account uses a KeyList or ThresholdKey


class LocalWallet implements Wallet

Static Methods

Constructor

constructor()

Creates an LocalWallet from the environment variables OPERATOR_KEY, OPERATOR_ID, and HEDERA_NEWTORK


Methods

async sign ( messages: List < bytes > ): List < List < SignatureProviderSignature > >

Signs all the messages with all the private keys.


class VoidSigner extends Signer

Static Methods

withAccountId ( accountId: AccountId ): VoidSigner

Create a wallet using a private key


Methods

getProvider (): Provider

Return the provider


getAccountKey (): Key

Will return null


async signTransaction ( transaction: Transaction ): Transaction

Will do nothing


async sendRequest ( request: Executable ): Response

Will throw an error as sending a transaction using a VoidSigner is not supported.


All SDK request types should add these methods

async freezeWithSigner ( signer: Signer ): Transaction

Freezes this transaction using the provided signer; calls Signer.populateTransaction


async signWithSigner ( signer: Signer ): Transaction

Signs this transaction using the provided signer; calls Signer.signTransaction


async executeWithSigner ( signer: Signer ): TransactionResponse

Executes this transaction using the provided signer; calls Signer.sendRequest


The difference between Signer and Provider is the Signer signs requests while Provider provides the network to which this request should be submitted to.

MyHbarWallet Extension Example

/**
 * The following two classes are examples of what `MyHbarWallet` can create to allow users
 * to easily use them as Hedera wallets, signers, and providers.
 */

/**
 * MyHbarWallet can inject this class into the DOM at global scope
 */
class MyHbarWalletWallet extends Wallet {
    private accountId: AccountId;
    private provider: MyHbarWalletProvider;

    private constructor(privateKey: PrivateKey, accountId: AccountId | null, ledgerId: LedgerId) {
        super();

        /**
         * Use secure storage for the public key and transaction signer
         * No access to the private key
         */
        window.sessionStorage.setItem("publicKey", privateKey.publicKey);
        window.sessionStorage.setItem("transactionSigner", privateKey.sign);

        this.accountId = accountId != null
            ? accountId
            : privateKey.publicKey.toAccountId(0, 0);

        this.provider = MyHbarWalletProvider.forLedgerId(ledgerId);
    }

    /**
     * Perhaps MyHbarWallet could add a method on this globally accessible class
     * to easily get the currently logged in user's wallet or initiate the logging
     * in process.
     */
    static getCurrent(): MyHbarWalletWallet {
        // ...
    }

    getProvider(): Provider {
        return this.provider;
    }

    getAccountKey(): Key {
        return window.sessionStorage.getItem("publicKey");
    }

    static async createRandomED25519(): Promise<MyHbarWalletWallet> {
        return MyHbarWalletWallet(await PrivateKey.generateED25519Async());
    }

    static async createRandomECDSA(): Promise<MyHbarWalletWallet> {
        return MyHbarWalletWallet(await PrivateKey.generateECDSA());
    }

    async sign(messages: Uint8Array[]): Promise<SignerSignature[][]> {
        return Promise.resolve(messages.map((message) => {
            return [window.sessionStorage.getItem("transactionSigner")(message)];
        }));
    }

    async signTransaction(transaction: Transaction): Promise<Transaction> {
        const publicKey = window.sessionStorage.getItem("publicKey");
        const transactionSigner = window.sessionStorage.getItem("transactionSigner");
        await transaction.signWith(publicKey, transactionSigner);
        return transaction;
    }
    
    sendRequest<RequestT, ResponseT, OutputT>(request: Executable<RequestT, ResponseT, OutputT>): Promise<OutputT> {
        return this.provider.sendRequest(request);
    }

    async checkTransaction(transaction: Transaction): Promise<Transaction> {
        return Promise.resovle(() => {
            const transactionId = transaction.transactionId;

            if (transactionId != null && transactionId.accountId.toString() != this.accountId.toString()) {
                throw new Error("TransactionID already set to a different account");
            }

            const nodeAccountIds = transaction.nodeAccountIds
                .map((nodeAccountId) => nodeAccountId.toString());
            const network = Object.values(this.provider.client.network)
                .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 transaction;
        });
    }

    async populateTransaction(transaction: Transaction): Promise<Transaction> {
        await this.checkTransaction(transaction);

        transaction.setTransactionId(Transaction.generate(this.accountId));
        transaction.setNodeAccountIds(Object.values(this.provider.client.network));

        return transaction;
    }

    getLedgerId(): LedgerId {
        return this.provider.getLedgerId();
    }

    getAccountId(): AccountId {
        return this.accountId;
    }

    getAccountBalance(): Promise<AccountBalance> {
        return this.provider.getAccountBalance(this.accountId);
    }

    getAccountInfo(): Promise<AccountInfo> {
        return this.provider.getAccountInfo(this.accountId);
    }

    getAccountRecords(): Promise<TransactionRecord[]> {
        return this.provider.getAccountRecords(this.accountId);
    }
}

/**
 * MyHbarWallet can inject this class into the DOM at global scope
 */
class MyHbarWalletProvider extends Provider {
    private client: Client;

    private constructor(ledgerId: LedgerId) {
        super();

        this.client = Client.forName(ledgerId.toString());
    }

    static forLedgerId(ledgerId: LedgerId): MyHbarWalletProvider {
        return new MyHbarWalletProvider(ledgerId);
    }

    getLedgerId(): LedgerId {
        return client.getLedgerId();
    }

    getNetwork(): Promise<{ [key: string]: AccountId }> {
        return Promise.resolve(client.network);
    }

    getMirrorNetwork(): Promise<string[]> {
        return Promise.resolve(client.mirrorNetwork);
    }

    getAccountBalance(accountId: AccountId): Promise<AccountBalance> {
        return new AccountBalanceQuery()
            .setAccountId(accountId)
            .execute(this.client);
    }

    getAccountInfo(accountId: AccountId): Promise<AccountInfo> {
        return new AccountInfoQuery()
            .setAccountId(accountId)
            .execute(this.client);
    }

    getAccountRecords(accountId: AccountId): Promise<TransactionRecord[]> {
        return new AccountRecordsQuery()
            .setAccountId(accountId)
            .execute(this.client);
    }

    sendRequest<RequestT, ResponseT, OutputT>(request: Executable<RequestT, ResponseT, OutputT>): Promise<OutputT> {
        return transaction.execute(this.client);
    }

    waitForReceipt(response: TransactionResponse): Promise<TransactionReceipt> {
        return response.getReceipt(this.client);
    }
}

/**
 * The following is an example of how someone can use the `MyHbarWalletWallet` from within
 * the browser class while on https://myhbarwallet.com
 */

const wallet = MyHbarWalletWallet.getCurrent();

async function main() {
    const balance = await wallet.getAccountBalance();
    console.log(`Current Balance for account: ${wallet.accountId.toString()} is ${balance.toString()}`);
}

MyHbarWallet Application Example

/**
 * The following two classes are examples of what `MyHbarWallet` can create to allow users
 * to easily use them as Hedera wallets, signers, and providers.
 * This could be put into a npm package.
 */

/**
 * This signer does not have access to the private key(s) for the account, instead
 * it uses the provider to communicate with MyHbarWallet extension which does the actual
 * signing and potentially sending of a transaction.
 */
class MyHbarWalletSigner extends Signer {
    private accountKey: Key;
    private accountId: AccountId;
    private provider: MyHbarWalletProvider;

    constructor(accountKey: Key, accountId: AccountId, ledgerId: LedgerId) {
        super();

        this.accountKey = accountKey;
        this.accountId = accountId;
        this.provider = new MyHbarWalletProvider(ledgerId);
    }

    static async login(accountId?: AccountId): Promise<MyHbarWalletSigner> {
        const response = await MyHbarWalletProvider.login(accountId);
        return new MyHbarWalletSigner(response.accountKey, response.accountId, response.ledgerId);
    }

    getProvider(): Provider {
        return this.provider;
    }

    getAccountKey(): Key {
        return this.accountKey;
    }

    static async createRandomED25519(): Promise<MyHbarWalletWallet> {
        const response = await this.provider.createRandomED25519();
        return new MyHbarWalletSigner(response.accountKey, response.accountId);
    }

    static async createRandomECDSA(): Promise<MyHbarWalletWallet> {
        const response = await this.provider.createRandomECDSA();
        return new MyHbarWalletSigner(response.accountKey, response.accountId);
    }

    sign(messages: Uint8Array[]): Promise<SignerSignature[][]> {
        return this.provider.sign(messages);
    }

    async signTransaction(transaction: Transaction): Promise<Transaction> {
        return this.provider.signTransaction(transaction);
    }
h
    sendRequest<RequestT, ResponseT, OutputT>(request: Executable<RequestT, ResponseT, OutputT>): Promise<OutputT> {
        return this.provider.sendRequest(request);
    }

    checkTransaction(transaction: Transaction): Promise<void> {
        return this.provider.checkTransaction(transaction);
    }

    populateTransaction(transaction: Transaction): Promise<void> {
        return this.provider.propulateTransaction(transaction);
    }

    getLedgerId(): LedgerId {
        return this.provider.getLedgerId();
    }

    getAccountId(): AccountId {
        return this.accountId;
    }

    getAccountBalance(): Promise<AccountBalance> {
        return this.provider.getAccountBalance(this.accountId);
    }

    getAccountInfo(): Promise<AccountInfo> {
        return this.provider.getAccountInfo(this.accountId);
    }

    getAccountRecords(): Promise<TransactionRecord[]> {
        return this.provider.getAccountRecords(this.accountId);
    }
}

/**
 * This provider is not a communciation with a Hedera network directly, but instead
 * communicates with MyHbarWallet which finally communicates with an actual Hedera
 * network.
 *
 * Note: This provider does not use JSON RPC it just uses a mock REST API. However,
 * MyHbarWallet is free to use whatever protocol they'd like since the SDK does
 * not put a requirement on the communication protocol.
 */
class MyHbarWalletProvider extends Provider {
    /**
     * Just an example endpoint
     */
    private static API_ENDPOINT: string = `https://myhbarwallet.com/api/v1/`;

    private ledgerId: LedgerId;

    constructor(ledgerId: LedgerId) {
        super();

        this.ledgerId = ledgerId;
    }

    static async login(accountId?: AccountId): Promise<{ accountId: AccountId, accountKey: Key, ledgerId: LedgerId }> {
        /**
         * Attempt to log into MyHbarWallet or let MyHbarWallet initiate the login process.
         * This could for instance communicate with MyHbarWallet extension and let the user
         * confirm which account they'd like to login as before proceeding.
         */
        const body = JSON.stringify({accountId: accountId?.toString() });
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/login`, { method: "POST", body });
        return response.json();
    }

    static async createRandomED25519(): Promise<{ accountKey: Key, accountId: AccountId, ledgerId: LedgerId }> {
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/create_random_ed25519`);
        return response.json();
    }

    static async createRandomECDSA(): Promise<{ accountKey: Key, accountId: AccountId, ledgerId: LedgerId }> {
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/create_random_ecdsa`);
        return response.json();
    }

    getLedgerId(): LedgerId {
        return this.ledgerId;
    }

    async getNetwork(): Proimse<{ [key: string]: AccountId }> {
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/get_network`);
        return response.json()
    }

    async getMirrorNetwork(): Proimse<string[]> {
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/get_mirror_network`);
        return response.json().mirrorNetwork;
    }

    async sign(messages: UintArray[]): Promise<SignerSignature[][]> {
        const body = JSON.stringify({messages: messages.map((message) => hex.encode(transaction.toBytes()))});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/sign_message`, {method: "POST", body});
        return response.json().signerSignature.map((signatureSignure) => SignerSignature.fromJson(signatureSignature));
    }

    async signTransaction(transaction: Transaction): Promise<Transaction> {
        const body = JSON.stringify({transaction: hex.encode(transaction.toBytes())});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/sign_transaction`, {method: "POST", body});
        return Transaction.fromBytes(hex.decode(response.json().transaction));
    }

    async sendRequest<RequestT, ResponseT, OutputT>(request: Executable<RequestT, ResponseT, OutputT>): Promise<OutputT> {
        const body = JSON.stringify({request: hex.encode(request.toBytes())});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/send_request`, {method: "POST", body});
        // Decode Response into correct type
        return decodedResponse;
    }

    async checkTransaction(transaction: Transaction): Promise<Transaction> {
        const body = JSON.stringify({transaction: hex.encode(transaction.toBytes())});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/check_transaction`, {method: "POST", body});
        return Transaction.fromBytes(hex.decode(response.json().transaction));
    }

    async populateTransaction(transaction: Transaction): Promise<Transaction> {
        const body = JSON.stringify({transaction: hex.encode(transaction.toBytes())});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/populate_transaction`, {
            method: "POST",
            body
        });
        return Transaction.fromBytes(hex.decode(response.json().transaction));
    }

    async getAccountBalance(accountId: AccountId): Promise<AccountBalance> {
        const body = JSON.stringify({accountId});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/get_account_balance`, {
            method: "POST",
            body
        });
        return AccountBalance.fromJson(response.json());
    }

    async getAccountInfo(accountId: AccountId): Promise<AccountInfo> {
        const body = JSON.stringify({accountId});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/get_account_info`, {method: "POST", body});
        return AccountInfo.fromJson(response.json());
    }

    async getAccountRecords(accountId: AccountId): Promise<TransactionRecord[]> {
        const body = JSON.stringify({accountId});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/get_account_records`, {
            method: "POST",
            body
        });
        return response.json().transactionRecords.map((record) => TransactionRecord.fromJson(record));
    }

    async waitForReceipt(transactionResponse: TransactionResponse): Promise<TransactionReceipt> {
        const body = JSON.stringify({transactionResponse});
        const response = await fetch(`${MyHbarWalletProvider.API_ENDPOINT}/wait_for_receipt`, {method: "POST", body});
        return TransactionReceipt.fromJson(response.json());
    }
}

/**
 * The following is an example of how a user can use MyHbarWallet's package to log into
 * their account and query they're balance
 */

async function main() {
    const signer = await MyHbarWalletSigner.login();

    const balance = await signer.getAccountBalance();
    console.log(`Current Balance for account: ${signer.getAccountId().toString()} is ${balance.toString()}`);

    // Query the account info using the signer directly
    let info = await signer.sendQuery(new AccountInfoQuery().setAccountId(signer.getAccountId()));
    console.log(`The account info for the current account is: ${info.toString()}`);
    
    // Query the account info using the SDK's `executeWithSigner()` method
    info = await new AccountInfoQuery()
        .setAccountId(signer.getAccountId())
        .executeWithSigner(signer);
    console.log(`The account info for the current account is: ${info.toString()}`);

    // You're also able to freeze and sign transactions using `*WithSigner()` metohds
    const frozenTransferTransaction = await new TransferTransaction()
        .addHbarTransfer("0.0.3", 1)
        .addHbarTransfer(signer.getAccountId(), -1)
        .freezeWithSigner(signer);
    
    const signedTransferTransaction = await frozenTransferTransaction.signWithSigner(signer);
        
}

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: