From 889a3f5729650e0ede1d46ebecde801b065efa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Szynwelski?= Date: Wed, 25 Oct 2023 21:05:49 +0200 Subject: [PATCH] Client for sending interactions to the sequencer --- package.json | 1 + .../decentralized-sequencer/interactions.ts | 150 +++++++++++++ .../decentralized-sequencer/send-data-item.ts | 110 +++++++++ src/contract/Contract.ts | 3 +- src/contract/HandlerBasedContract.ts | 56 +++-- src/contract/Signature.ts | 1 + .../sequencer/CentralizedSequencerClient.ts | 61 +++++ .../sequencer/DecentralizedSequencerClient.ts | 211 ++++++++++++++++++ src/contract/sequencer/SequencerClient.ts | 84 +++++++ src/core/KnownTags.ts | 1 + src/core/modules/StateEvaluator.ts | 5 +- src/utils/utils.ts | 9 +- 12 files changed, 670 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/integration/decentralized-sequencer/interactions.ts create mode 100644 src/__tests__/integration/decentralized-sequencer/send-data-item.ts create mode 100644 src/contract/sequencer/CentralizedSequencerClient.ts create mode 100644 src/contract/sequencer/DecentralizedSequencerClient.ts create mode 100644 src/contract/sequencer/SequencerClient.ts diff --git a/package.json b/package.json index 80feb719..0b98557b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:integration:basic": "jest ./src/__tests__/integration/basic", "test:integration:basic:load": "jest --silent=false --detectOpenHandles ./src/__tests__/integration/basic/contract-loading.test.ts ", "test:integration:basic:arweave": "jest ./src/__tests__/integration/basic/arweave-transactions-loading", + "test:integration:decentralized-sequencer": "jest ./src/__tests__/integration/decentralized-sequencer --detectOpenHandles", "test:integration:internal-writes": "jest ./src/__tests__/integration/internal-writes", "test:integration:wasm": "jest ./src/__tests__/integration/wasm", "test:regression": "node ./node_modules/.bin/jest ./src/__tests__/regression", diff --git a/src/__tests__/integration/decentralized-sequencer/interactions.ts b/src/__tests__/integration/decentralized-sequencer/interactions.ts new file mode 100644 index 00000000..655eedc4 --- /dev/null +++ b/src/__tests__/integration/decentralized-sequencer/interactions.ts @@ -0,0 +1,150 @@ +import ArLocal from 'arlocal'; +import Arweave from 'arweave'; +import { JWKInterface } from 'arweave/node/lib/wallet'; +import fs from 'fs'; +import path from 'path'; +import { createServer, Server } from 'http'; +import { DeployPlugin, ArweaveSigner } from 'warp-contracts-plugin-deploy'; +import { Contract, WriteInteractionResponse } from '../../../contract/Contract'; +import { Warp } from '../../../core/Warp'; +import { WarpFactory, defaultCacheOptions, defaultWarpGwOptions } from '../../../core/WarpFactory'; +import { SourceType } from '../../../core/modules/impl/WarpGatewayInteractionsLoader'; +import { AddressInfo } from 'net'; +import { WARP_TAGS } from '../../../core/KnownTags'; + +interface ExampleContractState { + counter: number; +} + +// FIXME: change to the address of the sequencer on dev +const DECENTRALIZED_SEQUENCER_URL = 'http://sequencer-0.warp.cc:1317'; + +describe('Testing sending of interactions to a decentralized sequencer', () => { + let contractSrc: string; + let initialState: string; + let wallet: JWKInterface; + let arlocal: ArLocal; + let warp: Warp; + let contract: Contract; + let sequencerServer: Server; + let centralizedSeqeuencerUrl: string; + let centralizedSequencerType: boolean; + + beforeAll(async () => { + const port = 1813; + arlocal = new ArLocal(port, false); + await arlocal.start(); + + const arweave = Arweave.init({ + host: 'localhost', + port: port, + protocol: 'http' + }); + + // a mock server simulating a centralized sequencer + centralizedSequencerType = false; + sequencerServer = createServer((req, res) => { + if (req.url === '/gateway/sequencer/address') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + url: centralizedSequencerType ? centralizedSeqeuencerUrl : DECENTRALIZED_SEQUENCER_URL, + type: centralizedSequencerType ? 'centralized' : 'decentralized' + })); + return; + } else if (req.url === '/gateway/v2/sequencer/register') { + centralizedSequencerType = false; + res.writeHead(301, { Location: DECENTRALIZED_SEQUENCER_URL }); + res.end(); + return; + } + throw new Error("Unexpected sequencer path: " + req.url); + }) + await new Promise(resolve => { + sequencerServer.listen(() => { + const address = sequencerServer.address() as AddressInfo + centralizedSeqeuencerUrl = `http://localhost:${address.port}` + resolve() + }) + }) + + const cacheOptions = { + ...defaultCacheOptions, + inMemory: true + } + const gatewayOptions = { ...defaultWarpGwOptions, source: SourceType.WARP_SEQUENCER, confirmationStatus: { notCorrupted: true } } + + warp = WarpFactory + .custom(arweave, cacheOptions, 'custom') + .useWarpGateway(gatewayOptions, cacheOptions) + .build() + .use(new DeployPlugin()); + + ({ jwk: wallet } = await warp.generateWallet()); + + contractSrc = fs.readFileSync(path.join(__dirname, '../data/example-contract.js'), 'utf8'); + initialState = fs.readFileSync(path.join(__dirname, '../data/example-contract-state.json'), 'utf8'); + + const { contractTxId } = await warp.deploy({ + wallet: new ArweaveSigner(wallet), + initState: initialState, + src: contractSrc + }); + + contract = warp.contract(contractTxId).setEvaluationOptions({ + sequencerUrl: centralizedSeqeuencerUrl + }); + contract.connect(wallet); + + }); + + afterAll(async () => { + await arlocal.stop(); + await new Promise(resolve => { + sequencerServer.close(resolve) + }) + }); + + const getNonceFromResult = (result: WriteInteractionResponse | null): number => { + if (result) { + for (let tag of result.interactionTx.tags) { + if (tag.name === WARP_TAGS.SEQUENCER_NONCE) { + return Number(tag.value) + } + } + } + return -1 + } + + it('should add new interactions waiting for confirmation from the sequencer', async () => { + contract.setEvaluationOptions({ waitForConfirmation: true }) + + await contract.writeInteraction({ function: 'add' }); + const result = await contract.writeInteraction({ function: 'add' }); + expect(getNonceFromResult(result)).toEqual(1) + expect(result?.bundlrResponse).toBeUndefined(); + expect(result?.sequencerTxHash).toBeDefined(); + }); + + it('should add new interactions without waiting for confirmation from the sequencer', async () => { + contract.setEvaluationOptions({ waitForConfirmation: false }) + + await contract.writeInteraction({ function: 'add' }); + const result = await contract.writeInteraction({ function: 'add' }); + expect(getNonceFromResult(result)).toEqual(3) + expect(result?.bundlrResponse).toBeUndefined(); + expect(result?.sequencerTxHash).toBeUndefined(); + }); + + it('should follow the redirection returned by the centralized sequencer.', async () => { + centralizedSequencerType = true; + contract.setEvaluationOptions({ + sequencerUrl: centralizedSeqeuencerUrl, + waitForConfirmation: true + }); + + const result = await contract.writeInteraction({ function: 'add' }); + expect(getNonceFromResult(result)).toEqual(4) + expect(result?.bundlrResponse).toBeUndefined(); + expect(result?.sequencerTxHash).toBeDefined(); + }); +}); diff --git a/src/__tests__/integration/decentralized-sequencer/send-data-item.ts b/src/__tests__/integration/decentralized-sequencer/send-data-item.ts new file mode 100644 index 00000000..2b237b39 --- /dev/null +++ b/src/__tests__/integration/decentralized-sequencer/send-data-item.ts @@ -0,0 +1,110 @@ +import Arweave from 'arweave'; +import { createData, DataItem, Signer } from 'warp-arbundles'; +import { ArweaveSigner } from 'warp-contracts-plugin-deploy'; +import { DecentralizedSequencerClient } from '../../../contract/sequencer/DecentralizedSequencerClient'; +import { SMART_WEAVE_TAGS, WARP_TAGS } from '../../../core/KnownTags'; +import { Tag } from '../../../utils/types/arweave-types'; +import { WarpFactory } from '../../../core/WarpFactory'; +import { WarpFetchWrapper } from '../../../core/WarpFetchWrapper'; +import { Signature } from '../../../contract/Signature'; + +// FIXME: change to the address of the sequencer on dev +const SEQUENCER_URL = 'http://sequencer-0.warp.cc:1317'; + +describe('Testing a decentralized sequencer client', () => { + let client: DecentralizedSequencerClient; + + beforeAll(async () => { + const warpFetchWrapper = new WarpFetchWrapper(WarpFactory.forLocal()) + client = new DecentralizedSequencerClient(SEQUENCER_URL, warpFetchWrapper); + }); + + const createSignature = async (): Promise => { + const wallet = await Arweave.crypto.generateJWK(); + const signer = new ArweaveSigner(wallet); + return new Signature(WarpFactory.forLocal(), signer) + } + + const createDataItem = async (signature: Signature, nonce: number, addNonceTag = true, addContractTag = true, signDataItem = true): Promise => { + const signer = signature.bundlerSigner; + const tags: Tag[] = []; + if (addNonceTag) { + tags.push(new Tag(WARP_TAGS.SEQUENCER_NONCE, String(nonce))); + } + if (addContractTag) { + tags.push(new Tag(SMART_WEAVE_TAGS.CONTRACT_TX_ID, "unit test contract")); + } + const dataItem = createData('some data', signer, { tags }); + if (signDataItem) { + await dataItem.sign(signer); + } + return dataItem; + } + + it('should return consecutive nonces for a given signature', async () => { + const signature = await createSignature() + let nonce = await client.getNonce(signature); + expect(nonce).toEqual(0); + + nonce = await client.getNonce(signature); + expect(nonce).toEqual(1); + }); + + it('should reject a data item with an invalid nonce', async () => { + const signature = await createSignature() + const dataItem = await createDataItem(signature, 13); + + expect(client.sendDataItem(dataItem, false)) + .rejects + .toThrowError('account sequence mismatch, expected 0, got 13: incorrect account sequence'); + }); + + it('should reject a data item without nonce', async () => { + const signature = await createSignature() + const dataItem = await createDataItem(signature, 0, false); + + expect(client.sendDataItem(dataItem, true)) + .rejects + .toThrowError('no sequencer nonce tag'); + }); + + it('should reject a data item without contract', async () => { + const signature = await createSignature() + const dataItem = await createDataItem(signature, 0, true, false); + + expect(client.sendDataItem(dataItem, true)) + .rejects + .toThrowError('no contract tag'); + }); + + it('should reject an unsigned data item', async () => { + const signature = await createSignature() + const dataItem = await createDataItem(signature, 0, true, true, false); + + expect(client.sendDataItem(dataItem, true)) + .rejects + .toThrowError('data item verification error'); + }); + + it('should return a confirmed result', async () => { + const signature = await createSignature(); + const nonce = await client.getNonce(signature); + const dataItem = await createDataItem(signature, nonce); + const result = await client.sendDataItem(dataItem, true); + + expect(result.sequencerMoved).toEqual(false); + expect(result.bundlrResponse).toBeUndefined(); + expect(result.sequencerTxHash).toBeDefined(); + }); + + it('should return an unconfirmed result', async () => { + const signature = await createSignature(); + const nonce = await client.getNonce(signature); + const dataItem = await createDataItem(signature, nonce); + const result = await client.sendDataItem(dataItem, false); + + expect(result.sequencerMoved).toEqual(false); + expect(result.bundlrResponse).toBeUndefined(); + expect(result.sequencerTxHash).toBeUndefined(); + }); +}); diff --git a/src/contract/Contract.ts b/src/contract/Contract.ts index bf698589..e0d10282 100644 --- a/src/contract/Contract.ts +++ b/src/contract/Contract.ts @@ -12,7 +12,7 @@ import { Transaction } from '../utils/types/arweave-types'; export type BenchmarkStats = { gatewayCommunication: number; stateEvaluation: number; total: number }; -interface BundlrResponse { +export interface BundlrResponse { id: string; public: string; signature: string; @@ -23,6 +23,7 @@ export interface WriteInteractionResponse { bundlrResponse?: BundlrResponse; originalTxId: string; interactionTx: Transaction | DataItem; + sequencerTxHash?: string; } export interface DREContractStatusResponse { diff --git a/src/contract/HandlerBasedContract.ts b/src/contract/HandlerBasedContract.ts index 41191b66..01a2f3a7 100644 --- a/src/contract/HandlerBasedContract.ts +++ b/src/contract/HandlerBasedContract.ts @@ -20,7 +20,7 @@ import { Benchmark } from '../logging/Benchmark'; import { LoggerFactory } from '../logging/LoggerFactory'; import { Evolve } from '../plugins/Evolve'; import { ArweaveWrapper } from '../utils/ArweaveWrapper'; -import { getJsonResponse, isBrowser, sleep, stripTrailingSlash } from '../utils/utils'; +import { getJsonResponse, isBrowser, sleep } from '../utils/utils'; import { BenchmarkStats, Contract, @@ -41,6 +41,7 @@ import { ContractInteractionState } from './states/ContractInteractionState'; import { Crypto } from 'warp-isomorphic'; import { VrfPluginFunctions } from '../core/WarpPlugin'; import { createData, tagsExceedLimit, DataItem, Signer } from 'warp-arbundles'; +import { SequencerClient, createSequencerClient } from './sequencer/SequencerClient'; /** * An implementation of {@link Contract} that is backwards compatible with current style @@ -72,6 +73,7 @@ export class HandlerBasedContract implements Contract { private _children: HandlerBasedContract[] = []; private _interactionState; private _dreStates = new Map>>(); + private _sequencerClient: SequencerClient; constructor( private readonly _contractTxId: string, @@ -325,7 +327,8 @@ export class HandlerBasedContract implements Contract { tags: Tags; strict: boolean; vrf: boolean; - } + }, + sequencerRedirected = false ): Promise { this.logger.info('Bundle interaction input', input); @@ -337,27 +340,37 @@ export class HandlerBasedContract implements Contract { options.vrf ); - const response = this._warpFetchWrapper.fetch( - `${stripTrailingSlash(this._evaluationOptions.sequencerUrl)}/gateway/v2/sequencer/register`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream', - Accept: 'application/json' - }, - body: interactionDataItem.getRaw() - } + const sequencerClient = await this.getSequencerClient(); + const sendResponse = await sequencerClient.sendDataItem( + interactionDataItem, + this._evaluationOptions.waitForConfirmation ); - - const dataItemId = await interactionDataItem.id; + if (sendResponse.sequencerMoved) { + this.logger.info( + `The sequencer at the given address (${this._evaluationOptions.sequencerUrl}) is redirecting to a new sequencer` + ); + if (sequencerRedirected) { + throw new Error('Too many sequencer redirects'); + } + this._sequencerClient = null; + return this.bundleInteraction(input, options, true); + } return { - bundlrResponse: await getJsonResponse(response), - originalTxId: dataItemId, - interactionTx: interactionDataItem + bundlrResponse: sendResponse.bundlrResponse, + originalTxId: await interactionDataItem.id, + interactionTx: interactionDataItem, + sequencerTxHash: sendResponse.sequencerTxHash }; } + private async getSequencerClient(): Promise { + if (!this._sequencerClient) { + this._sequencerClient = await createSequencerClient(this._evaluationOptions.sequencerUrl, this._warpFetchWrapper); + } + return this._sequencerClient; + } + private async createInteractionDataItem( input: Input, tags: Tags, @@ -370,6 +383,12 @@ export class HandlerBasedContract implements Contract { await this.discoverInternalWrites(input, tags, transfer, strict, vrf); } + const sequencerClient = await this.getSequencerClient(); + const nonce = await sequencerClient.getNonce(this._signature); + if (nonce !== undefined) { + tags.push(new Tag(WARP_TAGS.SEQUENCER_NONCE, String(nonce))); + } + if (vrf) { tags.push(new Tag(WARP_TAGS.REQUEST_VRF, 'true')); } @@ -483,6 +502,9 @@ export class HandlerBasedContract implements Contract { if (!this.isRoot()) { throw new Error('Evaluation options can be set only for the root contract'); } + if (options.sequencerUrl) { + this._sequencerClient = null; + } this._evaluationOptions = { ...this._evaluationOptions, ...options diff --git a/src/contract/Signature.ts b/src/contract/Signature.ts index 81297fb7..e51eaec8 100644 --- a/src/contract/Signature.ts +++ b/src/contract/Signature.ts @@ -29,6 +29,7 @@ export class Signature { private readonly signatureProviderType: 'CustomSignature' | 'ArWallet' | 'BundlerSigner'; private readonly wallet; private cachedAddress?: string; + sequencerNonce: number; constructor(warp: Warp, walletOrSignature: SignatureProvider) { this.warp = warp; diff --git a/src/contract/sequencer/CentralizedSequencerClient.ts b/src/contract/sequencer/CentralizedSequencerClient.ts new file mode 100644 index 00000000..92b86efc --- /dev/null +++ b/src/contract/sequencer/CentralizedSequencerClient.ts @@ -0,0 +1,61 @@ +import { BundlrResponse } from 'contract/Contract'; +import { WarpFetchWrapper } from 'core/WarpFetchWrapper'; +import { NetworkCommunicationError, getJsonResponse } from '../../utils/utils'; +import { DataItem } from 'warp-arbundles'; +import { SendDataItemResponse, SequencerClient } from './SequencerClient'; + +/** + * Client for a centralized sequencer. + */ +export class CentralizedSequencerClient implements SequencerClient { + private registerUrl: string; + private warpFetchWrapper: WarpFetchWrapper; + + constructor(sequencerUrl: string, warpFetchWrapper: WarpFetchWrapper) { + this.registerUrl = `${sequencerUrl}/gateway/v2/sequencer/register`; + this.warpFetchWrapper = warpFetchWrapper; + } + + /** + * The sequencer does not have a nonce mechanism; therefore, the method returns undefined. + * @returns undefined + */ + getNonce(): Promise { + return Promise.resolve(undefined); + } + + /** + * It sends an interaction to the sequencer and checks if the response has a status of 301 (Moved Permanently). + */ + async sendDataItem(dataItem: DataItem): Promise { + const result = this.warpFetchWrapper.fetch(this.registerUrl, { + redirect: 'manual', + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + Accept: 'application/json' + }, + body: dataItem.getRaw() + }); + return getJsonResponse( + result, + (result) => { + return { + bundlrResponse: result as BundlrResponse, + sequencerMoved: false + }; + }, + async (response) => { + if (response.status == 301) { + return { + bundlrResponse: undefined, + sequencerMoved: true + }; + } + + const text = await response.text(); + throw new NetworkCommunicationError(`Wrong response code: ${response.status}. ${text}`); + } + ); + } +} diff --git a/src/contract/sequencer/DecentralizedSequencerClient.ts b/src/contract/sequencer/DecentralizedSequencerClient.ts new file mode 100644 index 00000000..3210934b --- /dev/null +++ b/src/contract/sequencer/DecentralizedSequencerClient.ts @@ -0,0 +1,211 @@ +import base64url from 'base64url'; +import { DataItem } from 'warp-arbundles'; +import { getJsonResponse, NetworkCommunicationError, sleep } from '../../utils/utils'; +import { LoggerFactory } from '../../logging/LoggerFactory'; +import { WarpFetchWrapper } from '../../core/WarpFetchWrapper'; +import { SendDataItemResponse, SequencerClient } from './SequencerClient'; +import { Signature } from 'contract/Signature'; + +type NonceResponse = { + address: string; + nonce: number; +}; + +type CheckTxResponse = { + confirmed: boolean; + txHash?: string; +}; + +/** + * Client for a decentralized sequencer. + */ +export class DecentralizedSequencerClient implements SequencerClient { + private readonly logger = LoggerFactory.INST.create('DecentralizedSequencerClient'); + + private nonceUrl: string; + private sendDataItemUrl: string; + private getTxUrl: string; + private warpFetchWrapper: WarpFetchWrapper; + + constructor(sequencerUrl: string, warpFetchWrapper: WarpFetchWrapper) { + this.nonceUrl = `${sequencerUrl}/api/v1/nonce`; + this.sendDataItemUrl = `${sequencerUrl}/api/v1/dataitem`; + this.getTxUrl = `${sequencerUrl}/api/v1/tx-data-item-id`; + this.warpFetchWrapper = warpFetchWrapper; + } + + /** + * Returns the sequence (nonce) for an account owned by a given signer. The result is stored in the signature class's counter. + * For subsequent interactions, the nonce will be retrieved from the signature's counter without communication with the sequencer. + * + * @param signature the signature for which the nonce is calculated + * @returns nonce + */ + async getNonce(signature: Signature): Promise { + const nonce = signature.sequencerNonce; + if (nonce !== undefined) { + signature.sequencerNonce = nonce + 1; + return nonce; + } + + return this.fetchNonce(signature); + } + + /** + * It retrieves the nonce from the sequencer for the next interaction. + */ + private async fetchNonce(signature: Signature): Promise { + const bundlerSigner = signature.bundlerSigner; + if (!bundlerSigner) { + throw new Error( + 'Signer not set correctly. To use the decentralized sequencer, one should use the BundlerSigner type.' + ); + } + + const signatureType = bundlerSigner.signatureType; + const owner = base64url.encode(bundlerSigner.publicKey); + + const response = this.warpFetchWrapper.fetch(this.nonceUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ signature_type: signatureType, owner }) + }); + + const nonceResponse = await getJsonResponse(response); + this.logger.info('Nonce for owner', { owner, nonceResponse }); + signature.sequencerNonce = nonceResponse.nonce + 1; + return nonceResponse.nonce; + } + + /** + * Broadcasts a data item to the sequencer network and optionally monitoring its inclusion in the blockchain. + * If the broadcasting is rejected by the node (e.g., during the CheckTx method), an error is thrown. + * If the option to wait for confirmation is selected, + * the hash of the sequencer transaction containing the interaction is returned. + * + * @param dataItem data item to be sent + * @param waitForConfirmation whether to wait for confirmation that data item has been included in the blockchain + * @returns hash of the sequencer transaction if wait for confirmation is selected + */ + async sendDataItem(dataItem: DataItem, waitForConfirmation: boolean): Promise { + const response = await this.sendDataItemWithRetry(dataItem); + + if (waitForConfirmation) { + response.sequencerTxHash = await this.confirmTx(await dataItem.id); + } + + return response; + } + + /** + * Sends a data item to the sequencer. + * It retries in case of 'Service Unavailable' status and throws an error if the interaction is rejected by the sequencer. + * + * @param dataItem data item to be sent + * @param numberOfTries the number of retries + */ + private async sendDataItemWithRetry(dataItem: DataItem, numberOfTries = 20): Promise { + if (numberOfTries <= 0) { + throw new Error( + `Failed to send the interaction (id = ${await dataItem.id}) to the sequencer despite multiple retries` + ); + } + + if (await this.tryToSendDataItem(dataItem)) { + return { sequencerMoved: false }; + } else { + await sleep(1000); + return this.sendDataItemWithRetry(dataItem, numberOfTries - 1); + } + } + + private async tryToSendDataItem(dataItem: DataItem): Promise { + const response = this.warpFetchWrapper.fetch(this.sendDataItemUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: dataItem.getRaw() + }); + + return getJsonResponse( + response, + () => true, + async (response) => { + if (response.status == 503) { + return false; + } + + if (response.status == 409) { + const error = await response.json(); + throw new Error( + `Interaction (id = ${await dataItem.id}) rejected by the sequencer due to an invalid nonce, error message: ${ + error.message.RawLog + }}` + ); + } + + if (response.status == 400) { + const error = await response.json(); + throw new Error( + `Interaction (id = ${await dataItem.id}) rejected by the sequencer: error type: ${ + error.type + }, error message: ${JSON.stringify(error.message)}` + ); + } + + const text = await response.text(); + throw new NetworkCommunicationError(`Wrong response code: ${response.status}. ${text}`); + } + ); + } + + /** + * It queries the sequencer every second to check if the data item is in the chain + * + * @param dataItem data item to be sent + * @param numberOfTries the number of retries + */ + private async confirmTx(dataItemId: string, numberOfTries = 20): Promise { + if (numberOfTries <= 0) { + throw new Error(`Failed to confirm of the interaction with id = ${dataItemId} in the sequencer network`); + } + + await sleep(1000); + + const result = await this.checkTx(dataItemId); + if (!result.confirmed) { + return this.confirmTx(dataItemId, numberOfTries - 1); + } + return result.txHash; + } + + private async checkTx(dataItemId: string): Promise { + const response = this.warpFetchWrapper.fetch(this.getTxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ data_item_id: dataItemId }) + }); + + return getJsonResponse( + response, + (result) => { + this.logger.info(`The transaction with hash ${result.tx_hash} confirmed.`); + return { confirmed: true, txHash: result.tx_hash }; + }, + async (response) => { + if (response.status == 404) { + this.logger.debug(`The transaction with data item id (${dataItemId}) not confirmed yet.`); + return { confirmed: false }; + } + + const text = await response.text(); + throw new NetworkCommunicationError(`${response.status}: ${text}`); + } + ); + } +} diff --git a/src/contract/sequencer/SequencerClient.ts b/src/contract/sequencer/SequencerClient.ts new file mode 100644 index 00000000..fe5be510 --- /dev/null +++ b/src/contract/sequencer/SequencerClient.ts @@ -0,0 +1,84 @@ +import { BundlrResponse } from 'contract/Contract'; +import { Signature } from 'contract/Signature'; +import { getJsonResponse, stripTrailingSlash } from '../../utils/utils'; +import { DataItem } from 'warp-arbundles'; +import { CentralizedSequencerClient } from './CentralizedSequencerClient'; +import { DecentralizedSequencerClient } from './DecentralizedSequencerClient'; +import { WarpFetchWrapper } from 'core/WarpFetchWrapper'; + +/** + * The return type of sending an interaction to the sequencer + */ +export type SendDataItemResponse = { + /** + * Whether the sequencer returned a "Moved Permanently" status with the address of the new sequencer + */ + sequencerMoved: boolean; + /** + * The response from the bundler if the sequencer sends an interaction there + */ + bundlrResponse?: BundlrResponse; + /** + * The transaction hash in the decentralized sequencer blockchain containing the data item + */ + sequencerTxHash?: string; +}; + +/** + * A client for connecting to the sequencer, including sending interactions to the sequencer. + */ +export interface SequencerClient { + /** + * It returns the nonce for the next interaction signed by a given signer. + * If the sequencer does not support nonces, it returns undefined. + */ + getNonce(signature: Signature): Promise; + + /** + * It sends an interaction in the form of a data item to the sequencer. + * Potentially waits for confirmation that the interaction has been included in the sequencer chain. + * + * @param dataItem interaction in the form of a data item + * @param waitForConfirmation whether to wait for confirmation that the interaction has been included in the chain + */ + sendDataItem(dataItem: DataItem, waitForConfirmation: boolean): Promise; +} + +/** + * The response type from an endpoint returning the address of the current sequencer. + */ +type SequencerAddress = { + /** + * The URL address of the sequencer + */ + url: string; + /** + * The type of sequencer + */ + type: 'centralized' | 'decentralized'; +}; + +/** + * It queries an endpoint with an address and sequencer type, and returns a client for that sequencer. + * + * @param sequencerUrl URL address with an endpoint that returns the sequencer's address + * @param warpFetchWrapper wrapper for fetch operation + * @returns client for the sequencer + */ +export const createSequencerClient = async ( + sequencerUrl: string, + warpFetchWrapper: WarpFetchWrapper +): Promise => { + const response = warpFetchWrapper.fetch(`${stripTrailingSlash(sequencerUrl)}/gateway/sequencer/address`); + const address = await getJsonResponse(response); + + if (address.type == 'centralized') { + return new CentralizedSequencerClient(address.url, warpFetchWrapper); + } + + if (address.type == 'decentralized') { + return new DecentralizedSequencerClient(address.url, warpFetchWrapper); + } + + throw new Error('Unknown sequencer type: ' + address.type); +}; diff --git a/src/core/KnownTags.ts b/src/core/KnownTags.ts index 70f66905..f60b1363 100644 --- a/src/core/KnownTags.ts +++ b/src/core/KnownTags.ts @@ -28,6 +28,7 @@ export const WARP_TAGS = { SEQUENCER_BLOCK_HEIGHT: 'Sequencer-Block-Height', SEQUENCER_BLOCK_ID: 'Sequencer-Block-Id', SEQUENCER_BLOCK_TIMESTAMP: 'Sequencer-Block-Timestamp', + SEQUENCER_NONCE: 'Sequencer-Nonce', INIT_STATE: 'Init-State', INIT_STATE_TX: 'Init-State-TX', INTERACT_WRITE: 'Interact-Write', diff --git a/src/core/modules/StateEvaluator.ts b/src/core/modules/StateEvaluator.ts index 0785a810..b4781058 100644 --- a/src/core/modules/StateEvaluator.ts +++ b/src/core/modules/StateEvaluator.ts @@ -159,8 +159,9 @@ export interface EvaluationOptions { // whether exceptions from given transaction interaction should be ignored ignoreExceptions: boolean; - // allow to wait for confirmation of the interaction transaction - this way - // you will know, when the new interaction is effectively available on the network + // Allows waiting for confirmation of the interaction. + // In the case of the 'disableBundling' option, the confirmation comes from the Arweave network, + // otherwise from the decentralized Warp Sequencer. waitForConfirmation: boolean; // whether the state cache should be updated after evaluating each interaction transaction. diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e7c69332..ec5933b8 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,6 +1,5 @@ /* eslint-disable */ import copy from 'fast-copy'; -import { Buffer } from 'warp-isomorphic'; import { KnownErrors } from '../core/modules/impl/handler/JsHandlerApi'; export const sleep = (ms: number): Promise => { @@ -95,7 +94,7 @@ export class NetworkCommunicationError extends Error { } } -export async function getJsonResponse(response: Promise): Promise { +export async function getJsonResponse(response: Promise, successCallback?: (result: any) => T, errorCallback?: (response: Response) => Promise): Promise { let r: Response; try { r = await response; @@ -104,12 +103,18 @@ export async function getJsonResponse(response: Promise): Promise