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 |
Table of Contents
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: