@nomad-xyz/sdk
Version:
531 lines (471 loc) • 15.5 kB
text/typescript
import { ethers, ContractTransaction, Overrides } from 'ethers';
import { keccak256 } from 'ethers/lib/utils';
import { BigNumber } from '@ethersproject/bignumber';
import { arrayify, hexlify } from '@ethersproject/bytes';
import { TransactionReceipt } from '@ethersproject/abstract-provider';
import { ErrorCode } from '@ethersproject/logger';
import { Logger } from '@ethersproject/logger';
import * as core from '@nomad-xyz/contracts-core';
import { utils } from '@nomad-xyz/multi-provider';
import { NomadContext } from '..';
import { MessageProof } from '../NomadContext';
import {
Dispatch,
ParsedMessage,
MessageStatus,
ReplicaStatusNames,
ReplicaMessageStatus,
} from './types';
import { MessageBackend } from '../messageBackend';
/**
* Parse a serialized Nomad message from raw bytes.
*
* @param message
* @returns
*/
export function parseMessage(message: string): ParsedMessage {
const buf = Buffer.from(arrayify(message));
const from = buf.readUInt32BE(0);
const sender = hexlify(buf.slice(4, 36));
const nonce = buf.readUInt32BE(36);
const destination = buf.readUInt32BE(40);
const recipient = hexlify(buf.slice(44, 76));
const body = hexlify(buf.slice(76));
return { from, sender, nonce, destination, recipient, body };
}
/**
* A deserialized Nomad message.
*/
export class NomadMessage<T extends NomadContext> {
readonly dispatch: Dispatch;
readonly message: ParsedMessage;
readonly home: core.Home;
readonly replica: core.Replica;
readonly context: T;
protected _confirmAt?: Date;
readonly _backend?: MessageBackend;
constructor(context: T, dispatch: Dispatch, _backend?: MessageBackend) {
this.context = context;
this._backend = _backend;
this.message = parseMessage(dispatch.args.message);
this.dispatch = dispatch;
this.home = context.mustGetCore(this.message.from).home;
this.replica = context.mustGetReplicaFor(
this.message.from,
this.message.destination,
);
}
get backend(): MessageBackend {
const backend = this._backend || this.context._backend;
if (!backend) {
throw new Error(`No backend in the context`);
}
return backend;
}
get messageHash(): string {
return this.dispatch.args.messageHash;
}
/**
* Instantiate one or more messages from a receipt.
*
* @param context the {@link NomadContext} object to use
* @param receipt the receipt
* @returns an array of {@link NomadMessage} objects
*/
static async baseFromReceipt<T extends NomadContext>(
context: T,
receipt: TransactionReceipt,
): Promise<NomadMessage<T>[]> {
const messages: NomadMessage<T>[] = [];
const home = core.Home__factory.createInterface();
for (const log of receipt.logs) {
try {
const parsed = home.parseLog(log);
if (parsed.name === 'Dispatch') {
const {
messageHash,
leafIndex,
destinationAndNonce,
committedRoot,
message,
} = parsed.args;
const dispatch: Dispatch = {
args: {
messageHash,
leafIndex,
destinationAndNonce,
committedRoot,
message,
},
transactionHash: receipt.transactionHash,
};
messages.push(new NomadMessage(context, dispatch));
}
} catch (e: unknown) {
console.log('Unexpected error', e);
const err = e as { code: ErrorCode; reason: string };
// Catch known errors that we'd like to squash
if (
err.code == Logger.errors.INVALID_ARGUMENT &&
err.reason == 'no matching event'
)
continue;
}
}
return messages;
}
/**
* Instantiate EXACTLY one message from a receipt.
*
* @param context the {@link NomadContext} object to use
* @param receipt the receipt
* @returns an array of {@link NomadMessage} objects
* @throws if there is not EXACTLY 1 dispatch in the receipt
*/
static async baseSingleFromReceipt<T extends NomadContext>(
context: T,
receipt: TransactionReceipt,
): Promise<NomadMessage<T>> {
const messages: NomadMessage<T>[] = await NomadMessage.baseFromReceipt(
context,
receipt,
);
if (messages.length !== 1) {
throw new Error('Expected single Dispatch in transaction');
}
return messages[0];
}
/**
* Instantiate one or more messages from a tx hash.
*
* @param context the {@link NomadContext} object to use
* @param nameOrDomain the domain on which the receipt was logged
* @param transactionHash the transaction hash on the origin chain
* @returns an array of {@link NomadMessage} objects
* @throws if there is no receipt for the TX
*/
static async baseFromTransactionHash<T extends NomadContext>(
context: T,
nameOrDomain: string | number,
transactionHash: string,
): Promise<NomadMessage<T>[]> {
const provider = context.mustGetProvider(nameOrDomain);
const receipt = await provider.getTransactionReceipt(transactionHash);
if (!receipt) {
throw new Error(`No receipt for ${transactionHash} on ${nameOrDomain}`);
}
return await NomadMessage.baseFromReceipt(context, receipt);
}
/**
* Instantiate EXACTLY one message from a transaction has.
*
* @param context the {@link NomadContext} object to use
* @param nameOrDomain the domain on which the receipt was logged
* @param transactionHash the transaction hash on the origin chain
* @returns an array of {@link NomadMessage} objects
* @throws if there is no receipt for the TX, or if not EXACTLY 1 dispatch in
* the receipt
*/
static async baseSingleFromTransactionHash<T extends NomadContext>(
context: T,
nameOrDomain: string | number,
transactionHash: string,
): Promise<NomadMessage<T>> {
const provider = context.mustGetProvider(nameOrDomain);
const receipt = await provider.getTransactionReceipt(transactionHash);
if (!receipt) {
throw new Error(`No receipt for ${transactionHash} on ${nameOrDomain}`);
}
return await NomadMessage.baseSingleFromReceipt(context, receipt);
}
static async baseFirstFromBackend<T extends NomadContext>(
context: T,
transactionHash: string,
): Promise<NomadMessage<T>> {
if (!context._backend) {
throw new Error(`No backend is set for the context`);
}
const dispatches = await context._backend.getDispatches(transactionHash, 1);
if (!dispatches || dispatches.length === 0) throw new Error(`No dispatch`);
const m = new NomadMessage(context, dispatches[0]);
return m;
}
static async baseFromMessageHash<T extends NomadContext>(
context: T,
messageHash: string,
): Promise<NomadMessage<T>> {
if (!context._backend) {
throw new Error(`No backend is set for the context`);
}
const dispatch = await context._backend.getDispatchByMessageHash(messageHash);
if (!dispatch) throw new Error(`No dispatch`);
const m = new NomadMessage(context, dispatch);
return m;
}
/**
* Get the `Relay` event associated with this message (if any)
*
* @returns An relay tx (if any)
*/
async getRelay(): Promise<string | undefined> {
return await this.backend.relayTx(this.messageHash);
}
/**
* Get the `Update` event associated with this message (if any)
*
* @returns An update tx (if any)
*/
async getUpdate(): Promise<string | undefined> {
return await this.backend.updateTx(this.messageHash);
}
/**
* Get the Replica `Process` event associated with this message (if any)
*
* @returns An process tx (if any)
*/
async getProcess(): Promise<string | undefined> {
return await this.backend.processTx(this.messageHash);
}
/**
* Returns the timestamp after which it is possible to process this message.
*
* Note: return the timestamp after which it is possible to process messages
* within an Update. The timestamp is most relevant during the time AFTER the
* Update has been Relayed to the Replica and BEFORE the message in question
* has been Processed.
*
* Considerations:
* - the timestamp will be 0 if the Update has not been relayed to the Replica
* - after the Update has been relayed to the Replica, the timestamp will be
* non-zero forever (even after all messages in the Update have been
* processed)
* - if the timestamp is in the future, the challenge period has not elapsed
* yet; messages in the Update cannot be processed yet
* - if the timestamp is in the past, this does not necessarily mean that all
* messages in the Update have been processed
*
* @returns The timestamp at which a message can confirm
*/
/**
* Calculates an expected confirmation timestamp from relayed event
*
* @returns Timestamp (if any)
*/
async confirmAt(messageHash: string): Promise<Date | undefined> {
const relayedAt = await this.backend.relayedAt(messageHash);
if (relayedAt) {
// Additional check for adequate numbers
if (relayedAt?.valueOf() <= 946684800000) {
throw new Error(
`RelayedAt could not be smaller than 946684800000 (2000-01-01)`,
);
}
const destinationDomainId = await this.backend.destinationDomainId(
messageHash,
);
// Destination domain must be present as long as relayed at is found already, since
// destination domain data is present in Dispatch event, and relay data at Relay event,
// which is later.
if (!destinationDomainId) {
throw new Error(`Destination domain is not present`);
}
const domain = this.context.getDomain(destinationDomainId);
if (domain === undefined) {
throw new Error(`Destination domain is not in the config`);
}
const optimisticSecondsUnparsed = domain.configuration.optimisticSeconds;
const optimisticSeconds: number =
typeof optimisticSecondsUnparsed === 'string'
? parseInt(optimisticSecondsUnparsed)
: optimisticSecondsUnparsed;
const confirmAt = new Date(
relayedAt.valueOf() + optimisticSeconds * 1000,
);
return confirmAt;
}
return undefined;
}
async process(overrides: Overrides = {}): Promise<ContractTransaction> {
return this.context.process(this, overrides);
}
/**
* Retrieve the replica status of this message.
*
* @returns The {@link ReplicaMessageStatus} corresponding to the solidity
* status of the message.
*/
async replicaStatus(): Promise<ReplicaMessageStatus> {
// backwards compatibility. Older replica versions returned a number,
// newer versions return a hash
let root: string | number = await this.replica.messages(this.leaf);
root = root as string | number;
// case one: root is 0
if (root === ethers.constants.HashZero || root === 0)
return { status: ReplicaStatusNames.None };
// case two: root is 2
const legacyProcessed = `0x${'00'.repeat(31)}02`;
if (root === legacyProcessed || root === 2)
return { status: ReplicaStatusNames.Processed };
// case 3: root is proven. Could be either the root, or the legacy proven
// status
const legacyProven = `0x${'00'.repeat(31)}01`;
if (typeof root === 'number') root = legacyProven;
return { status: ReplicaStatusNames.Proven, root: root as string };
}
/**
* Checks whether the message has been delivered.
*
* @returns true if processed, else false.
*/
async delivered(): Promise<boolean> {
const { status } = await this.replicaStatus();
return status === ReplicaStatusNames.Processed;
}
/**
* Returns a promise that resolves when the message has been delivered.
*
* WARNING: May never resolve. Oftern takes hours to resolve.
*
* @param opts Polling options.
*/
async wait(opts?: { pollTime?: number }): Promise<void> {
const interval = opts?.pollTime ?? 50;
// sad spider face
for (;;) {
if (await this.delivered()) {
return;
}
await utils.delay(interval);
}
}
/**
* Get the status of a message
*
* 0 = dispatched
* 1 = included
* 2 = relayed
* 3 = updated
* 4 = received
* 5 = processed
*
* @returns An record of all events and correlating txs
*/
async status(): Promise<MessageStatus | undefined> {
if (await this.getProcess()) return MessageStatus.Processed;
const confirmAt = await this.confirmAt(this.messageHash);
const now = new Date();
if (confirmAt && confirmAt < now) return MessageStatus.Relayed;
if (await this.getUpdate()) return MessageStatus.Included;
return MessageStatus.Dispatched;
}
/**
* The domain from which the message was sent
*/
get from(): number {
return this.message.from;
}
/**
* The domain from which the message was sent. Alias for `from`
*/
get origin(): number {
return this.from;
}
/**
* The name of the domain from which the message was sent
*/
get originName(): string {
return this.context.resolveDomainName(this.origin);
}
/**
* Get the name of this file in the s3 bucket
*/
get s3Name(): string {
const index = this.leafIndex.toNumber();
return `${this.originName}_${index}`;
}
/**
* Get the URI for the proof in S3
*/
get s3Uri(): string | undefined {
const s3 = this.context.conf.s3;
if (!s3) throw new Error('s3 data not configured');
const { bucket, region } = s3;
const root = `https://${bucket}.s3.${region}.amazonaws.com`;
return `${root}/${this.s3Name}`;
}
/**
* The identifier for the sender of this message
*/
get sender(): string {
return this.message.sender;
}
/**
* The domain nonce for this message
*/
get nonce(): number {
return this.message.nonce;
}
/**
* The destination domain for this message
*/
get destination(): number {
return this.message.destination;
}
/**
* The identifer for the recipient for this message
*/
get recipient(): string {
return this.message.recipient;
}
/**
* The message body
*/
get body(): string {
return this.message.body;
}
/**
* The keccak256 hash of the message body
*/
get bodyHash(): string {
return keccak256(this.body);
}
/**
* The hash of the transaction that dispatched this message
*/
get transactionHash(): string {
return this.dispatch.transactionHash;
}
/**
* The messageHash committed to the tree in the Home contract.
*/
get leaf(): string {
return this.dispatch.args.messageHash;
}
/**
* The index of the leaf in the contract.
*/
get leafIndex(): BigNumber {
return this.dispatch.args.leafIndex;
}
/**
* The destination and nonceof this message.
*/
get destinationAndNonce(): BigNumber {
return this.dispatch.args.destinationAndNonce;
}
/**
* The committed root when this message was dispatched.
*/
get committedRoot(): string {
return this.dispatch.args.committedRoot;
}
/**
* Get the proof associated with this message
*
* @returns a proof, or undefined if no proof available
* @throws if s3 is not configured for this env
*/
async getProof(): Promise<MessageProof | undefined> {
return this.context.fetchProof(this.origin, this.leafIndex.toNumber());
}
}