Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend wallet-account to support signAndSendTransactionAsync, signTransaction, sendTransaction & signDelegateAction #1449

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
7 changes: 7 additions & 0 deletions .changeset/nervous-walls-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@near-js/accounts": minor
"@near-js/wallet-account": minor
"@near-js/providers": minor
---

Extend wallet-account
89 changes: 89 additions & 0 deletions packages/accounts/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,95 @@ export class Account implements IntoConnection {
return result;
}

/**
* Sign and send a transaction asynchronously, returning the transaction hash immediately
* without waiting for the transaction to complete.
*
* @param options The options for signing and sending the transaction
* @returns Promise<string> The base58-encoded transaction hash
*/
async signAndSendTransactionAsync({ receiverId, actions }: SignAndSendTransactionOptions): Promise<string> {
const result = await exponentialBackoff(TX_NONCE_RETRY_WAIT, TX_NONCE_RETRY_NUMBER, TX_NONCE_RETRY_WAIT_BACKOFF, async () => {
const [txHash, signedTx] = await this.signTransaction(receiverId, actions);
const publicKey = signedTx.transaction.publicKey;

try {
// Wait for the async operation to complete or throw
await this.connection.provider.sendTransactionAsync(signedTx);
return baseEncode(txHash);
} catch (error) {
if (error.type === 'InvalidNonce') {
Logger.warn(`Retrying transaction ${receiverId}:${baseEncode(txHash)} with new nonce.`);
delete this.accessKeyByPublicKeyCache[publicKey.toString()];
return null;
}
if (error.type === 'Expired') {
Logger.warn(`Retrying transaction ${receiverId}:${baseEncode(txHash)} due to expired block hash`);
return null;
}

error.context = new ErrorContext(baseEncode(txHash));
throw error;
}
});

if (!result) {
throw new TypedError('nonce retries exceeded for transaction. This usually means there are too many parallel requests with the same access key.', 'RetriesExceeded');
}

return result;
}

/**
* Send a signed transaction to the network and wait for its completion
*
* @param hash The hash of the transaction to be sent
* @param signedTx The signed transaction to be sent
* @returns {Promise<FinalExecutionOutcome>} A promise that resolves to the final execution outcome of the transaction
*/
async sendTransaction(hash: Uint8Array, signedTx: SignedTransaction): Promise<FinalExecutionOutcome> {
const result = await exponentialBackoff(TX_NONCE_RETRY_WAIT, TX_NONCE_RETRY_NUMBER, TX_NONCE_RETRY_WAIT_BACKOFF, async () => {
const publicKey = signedTx.transaction.publicKey;

try {
return await this.connection.provider.sendTransaction(signedTx);
} catch (error) {
if (error.type === 'InvalidNonce') {
Logger.warn(`Retrying transaction ${signedTx.transaction.receiverId}:${baseEncode(hash)} with new nonce.`);
delete this.accessKeyByPublicKeyCache[publicKey.toString()];
return null;
}
if (error.type === 'Expired') {
Logger.warn(`Retrying transaction ${signedTx.transaction.receiverId}:${baseEncode(hash)} due to expired block hash`);
return null;
}

error.context = new ErrorContext(baseEncode(hash));
throw error;
}
});
if (!result) {
// TODO: This should have different code actually, as means "transaction not submitted for sure"
throw new TypedError('nonce retries exceeded for transaction. This usually means there are too many parallel requests with the same access key.', 'RetriesExceeded');
}

printTxOutcomeLogsAndFailures({ contractId: signedTx.transaction.receiverId, outcome: result });

// Should be falsy if result.status.Failure is null
if (typeof result.status === 'object' && typeof result.status.Failure === 'object' && result.status.Failure !== null) {
// if error data has error_message and error_type properties, we consider that node returned an error in the old format
if (result.status.Failure.error_message && result.status.Failure.error_type) {
throw new TypedError(
`Transaction ${result.transaction_outcome.id} failed. ${result.status.Failure.error_message}`,
result.status.Failure.error_type);
} else {
throw parseResultError(result);
}
}
// TODO: if Tx is Unknown or Started.
return result;
}

/** @hidden */
accessKeyByPublicKeyCache: { [key: string]: AccessKeyView } = {};

Expand Down
191 changes: 190 additions & 1 deletion packages/wallet-account/src/wallet_account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { KeyStore } from '@near-js/keystores';
import { InMemorySigner } from '@near-js/signers';
import { FinalExecutionOutcome } from '@near-js/types';
import { baseDecode } from '@near-js/utils';
import { Transaction, Action, SCHEMA, createTransaction } from '@near-js/transactions';
import { Transaction, Action, SCHEMA, createTransaction, SignedTransaction, SignedDelegate } from '@near-js/transactions';
import { serialize } from 'borsh';

import { Near } from './near';
Expand Down Expand Up @@ -451,4 +451,193 @@ export class ConnectedWalletAccount extends Account {

return null;
}

/**
* Sign and send a transaction asynchronously by redirecting to the NEAR Wallet and waiting for the result
* @param options An optional options object
* @param options.receiverId The NEAR account ID of the transaction receiver.
* @param options.actions An array of transaction actions to be performed.
* @param options.walletMeta Additional metadata to be included in the wallet signing request.
* @param options.walletCallbackUrl URL to redirect upon completion of the wallet signing process. Default: current URL.
*/
async signAndSendTransactionAsync({ receiverId, actions, walletMeta, walletCallbackUrl = window.location.href }: SignAndSendTransactionOptions): Promise<string> {
const localKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId);
let accessKey = await this.accessKeyForTransaction(receiverId, actions, localKey);
if (!accessKey) {
throw new Error(`Cannot find matching key for transaction sent to ${receiverId}`);
}

if (localKey && localKey.toString() === accessKey.public_key) {
try {
return await super.signAndSendTransactionAsync({ receiverId, actions });
} catch (e) {
if (e.type === 'NotEnoughAllowance') {
accessKey = await this.accessKeyForTransaction(receiverId, actions);
} else {
throw e;
}
}
}

const block = await this.connection.provider.block({ finality: 'final' });
const blockHash = baseDecode(block.header.hash);
const publicKey = PublicKey.from(accessKey.public_key);
const nonce = Number(accessKey.access_key.nonce) + 1;
const transaction = createTransaction(this.accountId, publicKey, receiverId, nonce, actions, blockHash);

this.walletConnection.requestSignTransactions({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we need to be async as well? otherwise I think you are only making it async if there is a function call key available

transactions: [transaction],
meta: walletMeta,
callbackUrl: walletCallbackUrl
});

return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Failed to redirect to sign transaction'));
}, 1000);
});
}

/**
* Sign a transaction by redirecting to the NEAR Wallet
* @param options An optional options object
* @param options.receiverId The NEAR account ID of the transaction receiver.
* @param options.actions An array of transaction actions to be performed.
* @param options.walletMeta Additional metadata to be included in the wallet signing request.
* @param options.walletCallbackUrl URL to redirect upon completion of the wallet signing process. Default: current URL.
*/
async signTransaction(receiverId: string, actions: Action[], walletMeta?: string, walletCallbackUrl?: string): Promise<[Uint8Array, SignedTransaction]> {
const localKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId);
let accessKey = await this.accessKeyForTransaction(receiverId, actions, localKey);
if (!accessKey) {
throw new Error(`Cannot find matching key for transaction sent to ${receiverId}`);
}

if (localKey && localKey.toString() === accessKey.public_key) {
try {
return await super.signTransaction(receiverId, actions);
} catch (e) {
if (e.type === 'NotEnoughAllowance') {
accessKey = await this.accessKeyForTransaction(receiverId, actions);
} else {
throw e;
}
}
}

const block = await this.connection.provider.block({ finality: 'final' });
const blockHash = baseDecode(block.header.hash);
const publicKey = PublicKey.from(accessKey.public_key);
const nonce = Number(accessKey.access_key.nonce) + 1;
const transaction = createTransaction(this.accountId, publicKey, receiverId, nonce, actions, blockHash);

this.walletConnection.requestSignTransactions({
transactions: [transaction],
meta: walletMeta,
callbackUrl: walletCallbackUrl
});

return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Failed to redirect to sign transaction'));
}, 1000);
});
}

/**
* Send a signed transaction to the network and wait for its completion
*
* @param hash The hash of the transaction to be sent
* @param signedTx The signed transaction to be sent
* @param walletMeta Additional metadata to be included in the wallet signing request.
* @param walletCallbackUrl URL to redirect upon completion of the wallet signing process. Default: current URL.
*
* @returns {Promise<FinalExecutionOutcome>} A promise that resolves to the final execution outcome of the transaction
*/
async sendTransaction(hash: Uint8Array, signedTx: SignedTransaction, walletMeta?: string, walletCallbackUrl?: string): Promise<FinalExecutionOutcome> {
const localKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId);
let accessKey = await this.accessKeyForTransaction(signedTx.transaction.receiverId, signedTx.transaction.actions, localKey);
if (!accessKey) {
throw new Error(`Cannot find matching key for transaction sent to ${signedTx.transaction.receiverId}`);
}

if (localKey && localKey.toString() === accessKey.public_key) {
try {
return await super.sendTransaction(hash, signedTx);
} catch (e) {
if (e.type === 'NotEnoughAllowance') {
accessKey = await this.accessKeyForTransaction(signedTx.transaction.receiverId, signedTx.transaction.actions);
} else {
throw e;
}
}
}

const block = await this.connection.provider.block({ finality: 'final' });
const blockHash = baseDecode(block.header.hash);
const publicKey = PublicKey.from(accessKey.public_key);
const nonce = Number(accessKey.access_key.nonce) + 1;
const transaction = createTransaction(this.accountId, publicKey, signedTx.transaction.receiverId, nonce, signedTx.transaction.actions, blockHash);

this.walletConnection.requestSignTransactions({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are sending a transaction to the network, there is no need to request another signature on it

transactions: [transaction],
meta: walletMeta,
callbackUrl: walletCallbackUrl
});

return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Failed to redirect to sign transaction'));
}, 1000);
});
}

/**
* Sign a delegate action to be executed in a meta transaction on behalf of this account
*
* @param options Options for the delegate action
* @param options.actions Actions to be included in the meta transaction
* @param options.blockHeightTtl Number of blocks past the current block height for which the SignedDelegate action may be included in a meta transaction
* @param options.receiverId Receiver account of the meta transaction
* @param walletMeta Additional metadata to be included in the wallet signing request
* @param walletCallbackUrl URL to redirect upon completion of the wallet signing process. Default: current URL
*/
async signDelegateAction(actions: Action[], blockHeightTtl: number, receiverId: string, walletMeta?: string, walletCallbackUrl?: string): Promise<SignedDelegate> {
const localKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId);
let accessKey = await this.accessKeyForTransaction(receiverId, actions, localKey);

if (!accessKey) {
throw new Error(`Cannot find matching key for transaction sent to ${receiverId}`);
}

if (localKey && localKey.toString() === accessKey.public_key) {
try {
return await super.signedDelegate({ actions, blockHeightTtl, receiverId });
} catch (e) {
if (e.type === 'NotEnoughAllowance') {
accessKey = await this.accessKeyForTransaction(receiverId, actions);
} else {
throw e;
}
}
}

const block = await this.connection.provider.block({ finality: 'final' });
const blockHash = baseDecode(block.header.hash);
const publicKey = PublicKey.from(accessKey.public_key);
const nonce = Number(accessKey.access_key.nonce) + 1;
const transaction = createTransaction(this.accountId, publicKey, receiverId, nonce, actions, blockHash);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't you need to create a Delegate Transaction?


this.walletConnection.requestSignTransactions({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this command valid for signing delegate transactions?

transactions: [transaction],
meta: walletMeta,
callbackUrl: walletCallbackUrl
});

return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Failed to redirect to sign delegate action'));
}, 1000);
});
}
}
Loading
Loading