@vechain/sdk-network
Version:
This module serves as the standard interface connecting decentralized applications (dApps) and users to the VeChainThor blockchain
530 lines (488 loc) • 20.5 kB
text/typescript
import { concatBytes } from '@noble/curves/abstract/utils';
import {
Address,
Clause,
Hex,
HexUInt,
Keccak256,
Revision,
Txt,
type TransactionBody,
type TransactionClause
} from '@vechain/sdk-core';
import {
InvalidDataType,
JSONRPCInvalidParams,
SignerMethodError
} from '@vechain/sdk-errors';
import { hashTypedData } from 'viem';
import { RPC_METHODS } from '../../../provider/utils/const/rpc-mapper/rpc-methods';
import { type TransactionSimulationResult } from '../../../thor-client';
import { vnsUtils } from '../../../utils';
import {
type AvailableVeChainProviders,
type TransactionRequestInput,
type TypedDataDomain,
type TypedDataParameter,
type VeChainSigner
} from '../types';
import type { TypedDataDomain as viemTypedDataDomain } from 'viem';
/**
* Abstract VeChain signer.
* This abstract class avoids people every time implementing standard signer
* methods.
* By implementing this abstract class, it will be easier to create new signers
*/
abstract class VeChainAbstractSigner implements VeChainSigner {
protected readonly MESSAGE_PREFIX = Txt.of('\x19Ethereum Signed Message:\n')
.bytes;
/**
* The provider attached to this Signer (if any).
*/
provider?: AvailableVeChainProviders;
/**
* Create a new VeChainPrivateKeySigner.
* A signer can be initialized using a private key.
*
* @param provider - The provider to connect to
*/
protected constructor(provider?: AvailableVeChainProviders) {
// Store provider and gasPayer
this.provider = provider;
}
/**
* Returns a new instance of this Signer connected to //provider// or detached
* from any Provider if undefined.
*
* @param provider - The provider to connect to
* @returns a new instance of this Signer connected to //provider// or detached
*/
abstract connect(provider: AvailableVeChainProviders): this;
/**
* Get the address of the Signer.
*
* @returns the address of the signer
*/
abstract getAddress(): Promise<string>;
/**
* Prepares a {@link TransactionRequestInput} for calling:
* - resolves ``to`` and ``from`` addresses
* - if ``from`` is specified, check that it matches this Signer
*
* @note: Here the base support of multi-clause transaction is added.
* So, if clauses are provided in the transaction, it will be used as it is.
* Otherwise, standard transaction will be prepared.
*
* @param transactionToPopulate - The call to prepare
* @returns the prepared call transaction
* @throws {InvalidDataType}
*/
async populateCall(
transactionToPopulate: TransactionRequestInput
): Promise<TransactionRequestInput> {
// 1 - Add from field (if not provided)
if (
transactionToPopulate.from === undefined ||
transactionToPopulate.from === null
)
transactionToPopulate.from = Address.checksum(
HexUInt.of(await this.getAddress())
);
// Throw an error if the from address does not match the signer address
// @note: this because we cannot sign a transaction with a different address
else if (
Address.checksum(HexUInt.of(transactionToPopulate.from)) !==
Address.checksum(HexUInt.of(await this.getAddress()))
) {
throw new InvalidDataType(
'VeChainAbstractSigner.populateCall()',
'From address does not match the signer address.',
{
signerAddress: Address.checksum(
HexUInt.of(await this.getAddress())
),
fromAddress: Address.checksum(
HexUInt.of(transactionToPopulate.from)
)
}
);
}
// 2 - Set to field
if (transactionToPopulate.to === undefined)
transactionToPopulate.to = null;
// 3 - Use directly clauses, if they are provided
if (
transactionToPopulate.clauses !== undefined &&
transactionToPopulate.clauses.length > 0
) {
// 2.1 - Set to, value and data fields to be consistent
transactionToPopulate.to = transactionToPopulate.clauses[0].to;
transactionToPopulate.value =
transactionToPopulate.clauses[0].value;
transactionToPopulate.data = transactionToPopulate.clauses[0].data;
}
// Return the transaction
return transactionToPopulate;
}
/**
* Prepares a {@link TransactionRequestInput} for sending to the network by
* populating any missing properties:
* - resolves ``to`` and ``from`` addresses
* - if ``from`` is specified , check that it matches this Signer
* - populates ``nonce`` via ``signer.getNonce("pending")``
* - populates gas parameters via ``signer.estimateGas(tx)``
* - ... and other necessary properties
*
* @param transactionToPopulate - The call to prepare
* @returns the prepared transaction
* @throws {JSONRPCInvalidParams}
*/
async populateTransaction(
transactionToPopulate: TransactionRequestInput
): Promise<TransactionBody> {
// 1 - Get the thor client
if ((this.provider as AvailableVeChainProviders) === undefined) {
throw new JSONRPCInvalidParams(
'VechainAbstractSigner.populateTransaction()',
'Thor client not found into the signer. Please attach a Provider with a thor client to your signer instance.',
{ provider: this.provider }
);
}
const thorClient = (this.provider as AvailableVeChainProviders)
.thorClient;
// 2 - Populate the call, to get proper 'from' and 'to' address (compatible with multi-clause transactions)
const populatedTransaction = await this.populateCall(
transactionToPopulate
);
// 3 - Estimate gas
const totalGasResult =
transactionToPopulate.gas !== undefined
? Number(transactionToPopulate.gas)
: await this.estimateGas(transactionToPopulate);
// 4 - Build the transaction body
return await thorClient.transactions.buildTransactionBody(
populatedTransaction.clauses ??
this._buildClauses(populatedTransaction),
totalGasResult,
{
isDelegated: this.provider?.enableDelegation as boolean,
nonce:
populatedTransaction.nonce ??
(await this.getNonce('pending')),
blockRef: populatedTransaction.blockRef ?? undefined,
chainTag: populatedTransaction.chainTag ?? undefined,
dependsOn: populatedTransaction.dependsOn ?? undefined,
expiration: populatedTransaction.expiration,
gasPriceCoef: populatedTransaction.gasPriceCoef ?? undefined,
maxPriorityFeePerGas:
populatedTransaction.maxPriorityFeePerGas ?? undefined,
maxFeePerGas: populatedTransaction.maxFeePerGas ?? undefined
}
);
}
/**
* Estimates the gas required to execute //tx// on the Blockchain. This
* will be the expected amount a transaction will need
* to successfully run all the necessary computations and store the changed state
* that the transaction intends.
*
* @param transactionToEstimate - The transaction to estimate gas for
* @returns the total estimated gas required
* @throws {JSONRPCInvalidParams}
*/
async estimateGas(
transactionToEstimate: TransactionRequestInput
): Promise<number> {
// 1 - Get the thor client
if ((this.provider as AvailableVeChainProviders) === undefined) {
throw new JSONRPCInvalidParams(
'VechainAbstractSigner.estimateGas()',
'Thor client not found into the signer. Please attach a Provider with a thor client to your signer instance.',
{ provider: this.provider }
);
}
const thorClient = (this.provider as AvailableVeChainProviders)
.thorClient;
// 2 - Populate the call, to get proper from and to address (compatible with multi-clause transactions)
const populatedTransaction = await this.populateCall(
transactionToEstimate
);
// 3 - Estimate gas
const gasEstimation = await thorClient.transactions.estimateGas(
populatedTransaction.clauses ??
this._buildClauses(populatedTransaction),
populatedTransaction.from as string
);
// Return the gas estimation
return gasEstimation.totalGas;
}
/**
* Evaluates the //tx// by running it against the current Blockchain state. This
* cannot change state and has no cost, as it is effectively simulating
* execution.
*
* This can be used to have the Blockchain perform computations based on its state
* (e.g. running a Contract's getters) or to simulate the effect of a transaction
* before actually performing an operation.
*
* @param transactionToEvaluate - The transaction to evaluate
* @param revision - The block number or block ID of which the transaction simulation is based on
* @returns the result of the evaluation
* @throws {JSONRPCInvalidParams}
*/
async call(
transactionToEvaluate: TransactionRequestInput,
revision?: Revision
): Promise<string> {
// 1 - Get the thor client
if ((this.provider as AvailableVeChainProviders) === undefined) {
throw new JSONRPCInvalidParams(
'VechainAbstractSigner.call()',
'Thor client not found into the signer. Please attach a Provider with a thor client to your signer instance.',
{ provider: this.provider }
);
}
const thorClient = (this.provider as AvailableVeChainProviders)
.thorClient;
// 2 - Populate the call, to get proper from and to address (compatible with multi-clause transactions)
const populatedTransaction = await this.populateCall(
transactionToEvaluate
);
// 3 - Evaluate the transaction
const simulation: TransactionSimulationResult[] =
await thorClient.transactions.simulateTransaction(
populatedTransaction.clauses ??
this._buildClauses(populatedTransaction),
{
revision: revision ?? undefined,
gas: (populatedTransaction.gas as number) ?? undefined,
gasPrice: populatedTransaction.gasPrice ?? undefined,
caller: populatedTransaction.from as string,
provedWork: populatedTransaction.provedWork ?? undefined,
gasPayer: populatedTransaction.gasPayer ?? undefined,
expiration: populatedTransaction.expiration ?? undefined,
blockRef: populatedTransaction.blockRef ?? undefined
}
);
// 4 - Return the result of the evaluation
return simulation[0].data;
}
/**
* Gets the next nonce required for this Signer to send a transaction.
*
* @param blockTag - The blocktag to base the transaction count on, keep in mind
* many nodes do not honour this value and silently ignore it [default: ``"latest"``]
*
* @NOTE: This method generates a random number as nonce. It is because the nonce in VeChain is a 6-byte number.
*/
async getNonce(blockTag?: string): Promise<string> {
// If provider is available, get the nonce from the provider using eth_getTransactionCount
if (this.provider !== undefined) {
return (await this.provider.request({
method: RPC_METHODS.eth_getTransactionCount,
params: [await this.getAddress(), blockTag]
})) as string;
}
// Otherwise return a random number
return Hex.random(6).toString();
}
/**
* Signs %%transactionToSign%%, returning the fully signed transaction. This does not
* populate any additional properties with eth_getTransactionCount: RPC_METHODS, p0: (string | undefined)[], args: EIP1193RequestArguments* @param transactionToSign - The transaction to sign
* @returns The fully signed transaction
*/
abstract signTransaction(
transactionToSign: TransactionRequestInput
): Promise<string>;
/**
* Sends %%transactionToSend%% to the Network. The ``signer.populateTransaction(transactionToSend)``
* is called first to ensure all necessary properties for the
* transaction to be valid have been populated first.
*
* @param transactionToSend - The transaction to send
* @returns The transaction response
*/
abstract sendTransaction(
transactionToSend: TransactionRequestInput
): Promise<string>;
/**
* Signs a bytes payload returning the VeChain signature in hexadecimal format.
* @param {Uint8Array} payload in bytes to sign.
* @returns {string} The VeChain signature in hexadecimal format.
*/
abstract signPayload(payload: Uint8Array): Promise<string>;
/**
* Signs an [[link-eip-191]] prefixed a personal message.
*
* @param {string|Uint8Array} message - The message to be signed.
* If the %%message%% is a string, it is signed as UTF-8 encoded bytes.
* It is **not** interpreted as a [[BytesLike]];
* so the string ``"0x1234"`` is signed as six characters, **not** two bytes.
* @return {Promise<string>} - A Promise that resolves to the signature as a string.
*/
public async signMessage(message: string | Uint8Array): Promise<string> {
try {
const payload =
typeof message === 'string' ? Txt.of(message).bytes : message;
const payloadHashed = Keccak256.of(
concatBytes(
this.MESSAGE_PREFIX,
Txt.of(payload.length).bytes,
payload
)
).bytes;
return await this.signPayload(payloadHashed);
} catch (error) {
throw new SignerMethodError(
'VeChainAbstractSigner.signMessage',
'The message could not be signed.',
{ message },
error
);
}
}
/**
* Deduces the primary from the types if not given.
* The primary type will be the only type that is not used in any other type.
* @param {Record<string, TypedDataParameter[]>} types - The types used for EIP712.
* @returns {string} The primary type.
*/
private deducePrimaryType(
types: Record<string, TypedDataParameter[]>
): string {
const parents = new Map<string, string[]>();
// Initialize parents map
Object.keys(types).forEach((type) => {
parents.set(type, []);
});
// Populate parents map
for (const name in types) {
for (const field of types[name]) {
// In case the type is an array, we get its prefix
const type = field.type.split('[')[0];
if (parents.has(type)) {
parents.get(type)?.push(name);
}
}
}
// Find primary types
const primaryTypes = Array.from(parents.keys()).filter(
(n) => parents.get(n)?.length === 0
);
if (primaryTypes.length !== 1) {
throw new SignerMethodError(
'VeChainAbstractSigner.deducePrimaryType',
'Ambiguous primary types or unused types.',
{ primaryTypes: primaryTypes.join(', ') }
);
}
return primaryTypes[0];
}
/**
* Signs the [[link-eip-712]] typed data.
*
* @param {TypedDataDomain} domain - The domain parameters used for signing.
* @param {Record<string, TypedDataParameter[]>} types - The types used for signing.
* @param {Record<string, unknown>} message - The message data to be signed.
* @param {string} primaryType - The primary type used for signing.
*
* @return {Promise<string>} - A promise that resolves with the signature string.
*/
public async signTypedData(
domain: TypedDataDomain,
types: Record<string, TypedDataParameter[]>,
message: Record<string, unknown>,
primaryType?: string
): Promise<string> {
try {
const viemDomain: viemTypedDataDomain = {
chainId: undefined,
name: domain.name,
salt: domain.salt,
verifyingContract: domain.verifyingContract,
version: domain.version
};
// convert chainId
if (domain.chainId !== undefined) {
if (
typeof domain.chainId === 'string' ||
typeof domain.chainId === 'number'
) {
viemDomain.chainId = BigInt(domain.chainId);
} else if (typeof domain.chainId === 'bigint') {
viemDomain.chainId = domain.chainId;
} else {
throw new InvalidDataType(
'VeChainAbstractSigner.signTypedData',
'Invalid chainId type.',
{ chainId: domain.chainId }
);
}
}
const payload = Hex.of(
hashTypedData({
domain: viemDomain,
types,
primaryType: primaryType ?? this.deducePrimaryType(types), // Deduce the primary type if not provided
message
})
).bytes;
return await this.signPayload(payload);
} catch (error) {
throw new SignerMethodError(
'VeChainAbstractSigner.signTypedData',
'The typed data could not be signed.',
{ domain, types, message, primaryType },
error
);
}
}
/**
* Use vet.domains to resolve name to address
* @param vnsName - The name to resolve
* @returns the address for a name or null
*/
async resolveName(vnsName: string): Promise<null | string> {
if (this.provider === undefined) {
return null;
}
return await vnsUtils.resolveName(this.provider.thorClient, vnsName);
}
/**
* Build the transaction clauses
* form a transaction given as input
*
* @param transaction - The transaction to sign
* @returns The transaction clauses
*/
protected _buildClauses(
transaction: TransactionRequestInput
): TransactionClause[] {
return transaction.to !== undefined && transaction.to !== null
? // Normal transaction
[
{
to: transaction.to,
data: transaction.data ?? '0x',
value: transaction.value ?? '0x0'
} satisfies TransactionClause
]
: // If 'to' address is not provided, it will be assumed that the transaction is a contract creation transaction.
[
Clause.deployContract(
HexUInt.of(transaction.data ?? 0),
undefined,
{
value:
transaction.value === undefined
? transaction.value
: HexUInt.of(transaction.value).toString(
true
),
comment: transaction.comment
}
) as TransactionClause
];
}
}
export { VeChainAbstractSigner };