diff --git a/src/ExtrinsicSubmissionStateView.tsx b/src/ExtrinsicSubmissionStateView.tsx index b87747fe..a8860244 100644 --- a/src/ExtrinsicSubmissionStateView.tsx +++ b/src/ExtrinsicSubmissionStateView.tsx @@ -4,6 +4,7 @@ import ExtrinsicSubmissionResult from './ExtrinsicSubmissionResult'; export interface Props { successMessage?: string; slim?: boolean; + submissionId?: string; } export default function ExtrinsicSubmissionStateView(props: Props) { @@ -11,8 +12,8 @@ export default function ExtrinsicSubmissionStateView(props: Props) { if(extrinsicSubmissionState.submitted) { return (); diff --git a/src/common/WalletGauge.tsx b/src/common/WalletGauge.tsx index 0028f9ec..bc3755b7 100644 --- a/src/common/WalletGauge.tsx +++ b/src/common/WalletGauge.tsx @@ -59,8 +59,10 @@ export default function WalletGauge(props: Props) { }; }, [ amount, destination, unit, mutateBalanceState, signer, expectNewTransaction ]); - const transferCallback = useCallback(() => { - submitCall(transfer); + const transferCallback = useCallback(async () => { + try { + await submitCall(transfer); + } catch(e) {} }, [ transfer, submitCall ]); const clearFormCallback = useCallback(() => { @@ -131,7 +133,7 @@ export default function WalletGauge(props: Props) { buttonText: "Cancel", buttonVariant: 'secondary-polkadot', callback: cancelCallback, - disabled: extrinsicSubmissionState.submitted && !extrinsicSubmissionState.callEnded, + disabled: extrinsicSubmissionState.inProgress, }, { id: 'transfer', diff --git a/src/loc/CloseLocButton.tsx b/src/loc/CloseLocButton.tsx index c13bcb7b..c7b3b1dc 100644 --- a/src/loc/CloseLocButton.tsx +++ b/src/loc/CloseLocButton.tsx @@ -25,8 +25,7 @@ enum CloseStatus { NONE, ACCEPT, CLOSE_PENDING, - CLOSING, - DONE + CLOSING } interface CloseState { @@ -60,9 +59,11 @@ export default function CloseLocButton(props: Props) { }); }, [ mutateLocState, autoAck, signer ]); - const close = useCallback(() => { + const close = useCallback(async () => { setCloseState({ status: CloseStatus.CLOSING }); - submitCall(closeCall); + try { + await submitCall(closeCall); + } catch(e) {} }, [ closeCall, submitCall ]); const clear = useCallback(() => { diff --git a/src/loc/ImportItems.test.tsx b/src/loc/ImportItems.test.tsx index 2a068374..73624654 100644 --- a/src/loc/ImportItems.test.tsx +++ b/src/loc/ImportItems.test.tsx @@ -6,7 +6,6 @@ import { setLocState, refresh } from "./__mocks__/UserLocContextMock"; import { CollectionItem, Fees, UUID } from "@logion/node-api"; import { H256 } from "@logion/node-api/dist/types/interfaces"; import { LogionClient, AddCollectionItemParams, EstimateFeesAddCollectionItemParams } from "@logion/client"; -import { mockSubmittableResult } from "../logion-chain/__mocks__/SignatureMock"; import { SUCCESSFUL_SUBMISSION, setClientMock, setExtrinsicSubmissionState } from 'src/logion-chain/__mocks__/LogionChainMock'; import { It, Mock } from 'moq.ts'; import { SubmittableExtrinsic } from '@polkadot/api/promise/types'; @@ -14,7 +13,6 @@ import { Compact, u128 } from "@polkadot/types-codec"; import { TEST_WALLET_USER } from 'src/wallet-user/TestData'; jest.mock("../common/CommonContext"); -jest.mock("../logion-chain/Signature"); jest.mock("../logion-chain"); jest.mock("./UserLocContext"); @@ -29,9 +27,7 @@ describe("ImportItems", () => { await userEvent.click(importAllButton); await waitFor(() => expect(screen.getByRole("button", { name: "Proceed" })).toBeVisible()); await clickByName("Proceed"); - await waitFor(() => expect(screen.getByRole("button", { name: "Proceed" })).toBeVisible()); - await clickByName("Proceed"); - await waitFor(() => expect(collection.addCollectionItem).toBeCalled()); + await waitFor(() => expect(collection.addCollectionItem).toBeCalledTimes(2)); await waitFor(() => expect(screen.getAllByRole("img", {name: "ok"}).length).toBe(2)); @@ -69,7 +65,6 @@ async function uploadCsv(): Promise { const collection = { addCollectionItem: jest.fn((params: AddCollectionItemParams) => { - params.callback!(mockSubmittableResult(true, "finalized")); return Promise.resolve(); }), estimateFeesAddCollectionItem: jest.fn((params: EstimateFeesAddCollectionItemParams) => { diff --git a/src/loc/ImportItems.tsx b/src/loc/ImportItems.tsx index d5944307..b37921f8 100644 --- a/src/loc/ImportItems.tsx +++ b/src/loc/ImportItems.tsx @@ -10,7 +10,7 @@ import { CreativeCommons, } from "@logion/client"; import { Fees, Hash } from '@logion/node-api'; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { OverlayTrigger, Spinner, Tooltip } from "react-bootstrap"; import Dialog from "../common/Dialog"; @@ -25,28 +25,24 @@ import { useUserLocContext } from "./UserLocContext"; import ImportItemDetails, { ErrorType, Item } from "./ImportItemDetails"; import './ImportItems.css'; -import ClientExtrinsicSubmitter, { Call, CallCallback } from "../ClientExtrinsicSubmitter"; import { CsvItem, readItemsCsv } from "./ImportCsvReader"; import Alert from "src/common/Alert"; import { UUID } from "@logion/node-api"; import config from "../config"; import EstimatedFees from "./fees/EstimatedFees"; import { BrowserFile } from "@logion/client-browser"; - -type Submitters = Record; +import { Call, CallBatch, CallCallback } from "src/logion-chain/LogionChainContext"; +import ExtrinsicSubmissionStateView from "src/ExtrinsicSubmissionStateView"; export default function ImportItems() { const { width } = useResponsiveContext(); - const { signer } = useLogionChain(); + const { signer, submitCallBatch, extrinsicSubmissionState, clearSubmissionState } = useLogionChain(); const { colorTheme } = useCommonContext(); const { refresh, locState } = useUserLocContext(); const [ showImportItems, setShowImportItems ] = useState(false); const [ items, setItems ] = useState([]); const [ csvReadError, setCsvReadError ] = useState(""); - const [ submitters, setSubmitters ] = useState({}); - const [ currentItem, setCurrentItem ] = useState(0); - const [ isBatchImport, setIsBatchImport ] = useState(false); const [ uploading, setUploading ] = useState(false); const [ fees, setFees ] = useState(null); const [ itemToSubmit, setItemToSubmit ] = useState(); @@ -54,7 +50,7 @@ export default function ImportItems() { const readCsvFile = useCallback(async (file: File) => { const collection = locState as ClosedCollectionLoc; const acceptsUpload = collectionAcceptsUpload(collection); - setSubmitters({}); + clearSubmissionState(); const result = await readItemsCsv(file); if("items" in result) { @@ -74,95 +70,132 @@ export default function ImportItems() { setCsvReadError(result.error); } setShowImportItems(true); - }, [ setSubmitters, locState ]); + }, [ clearSubmissionState, locState ]); + + const itemFees = useCallback(async (item: Item) => { + const collection = locState as ClosedCollectionLoc; + return await collection.estimateFeesAddCollectionItem({ + itemId: item.id!, + itemDescription: item.description, + itemFiles: item.files, + restrictedDelivery: item.restrictedDelivery, + itemToken: item.token, + logionClassification: item.logionClassification, + specificLicenses: item.specificLicense ? [ item.specificLicense ] : undefined, + creativeCommons: item.creativeCommons, + }); + }, [ locState ]); const submitItem = useCallback(async (item: Item) => { if (locState) { setItemToSubmit(item); - const collection = locState as ClosedCollectionLoc; - const fees = await collection.estimateFeesAddCollectionItem({ - itemId: item.id!, - itemDescription: item.description, - itemFiles: item.files, - restrictedDelivery: item.restrictedDelivery, - itemToken: item.token, - logionClassification: item.logionClassification, - specificLicenses: item.specificLicense ? [ item.specificLicense ] : undefined, - creativeCommons: item.creativeCommons, - }) + const fees = await itemFees(item); setFees(fees); } - }, [ locState ]); - - const doSubmitItem = useCallback(async () => { - if(itemToSubmit) { - setFees(null); - - const collection = locState as ClosedCollectionLoc; - const item = itemToSubmit; - setItemToSubmit(undefined); - - item.submitted = true; - setItems(items.slice()); - const signAndSubmit: Call = async (callback: CallCallback) => { - await collection.addCollectionItem({ - signer: signer!, - itemId: item.id!, - itemDescription: item.description, - itemFiles: item.files, - restrictedDelivery: item.restrictedDelivery, - itemToken: item.token, - logionClassification: item.logionClassification, - specificLicenses: item.specificLicense ? [ item.specificLicense ] : undefined, - creativeCommons: item.creativeCommons, - callback, - }) - } - const newSubmitters = { ...submitters }; - newSubmitters[item.id!.toHex()] = signAndSubmit; - setSubmitters(newSubmitters); - } - }, [ submitters, signer, locState, itemToSubmit, items ]); - - const submitNext = useCallback(async (submittedItem: Item, failed: boolean) => { - if(failed) { - submittedItem.failed = true; - } else { - submittedItem.success = true; - } - setItems(items.slice()); - if(isBatchImport) { - const nextItemIndex = getNextItem(items, currentItem); - if(nextItemIndex < items.length) { - setCurrentItem(nextItemIndex); - await submitItem(items[nextItemIndex]); - } else { - setIsBatchImport(false); - } - } - }, [ isBatchImport, currentItem, items, setCurrentItem, submitItem, setIsBatchImport ]); + }, [ locState, itemFees ]); const cancelSubmission = useCallback(() => { - setIsBatchImport(false); setFees(null); setItemToSubmit(undefined); - }, []); + clearSubmissionState(); + }, [ clearSubmissionState ]); const close = useCallback(() => { setShowImportItems(false); - setIsBatchImport(false); setItems([]); refresh(); - }, [ setShowImportItems, setIsBatchImport, refresh ]); + clearSubmissionState(); + }, [ setShowImportItems, refresh, clearSubmissionState ]); + + const callMap = useMemo(() => { + const collection = locState as ClosedCollectionLoc; + const map: Record = {}; + items.forEach(item => { + if(!item.submitted && item.id) { + map[item.id.toHex()] = async (callback: CallCallback) => { + await collection.addCollectionItem({ + signer: signer!, + itemId: item.id!, + itemDescription: item.description, + itemFiles: item.files, + restrictedDelivery: item.restrictedDelivery, + itemToken: item.token, + logionClassification: item.logionClassification, + specificLicenses: item.specificLicense ? [ item.specificLicense ] : undefined, + creativeCommons: item.creativeCommons, + callback, + }) + }; + } + }); + return map; + }, [ items, locState, signer ]); + + const callBatch = useMemo(() => { + return new CallBatch(Object.keys(callMap).map(submissionId => ({ + submissionId, + call: callMap[submissionId], + }))); + }, [ callMap ]); + + const doSubmitItem = useCallback(async () => { + setFees(null); + if(itemToSubmit && itemToSubmit.id) { + const submissionId = itemToSubmit.id.toHex(); + const errors = await submitCallBatch(CallBatch.fromSingleWithId(submissionId, callMap[submissionId])); + setItems(items.map(item => { + if(item === itemToSubmit) { + const error = errors[submissionId]; + return { + ...item, + submitted: true, + errorType: error ? "chain" : undefined, + error: error ? String(error) : undefined, + success: !error, + }; + } else { + return item; + } + })); + } else { + const errors = await submitCallBatch(callBatch); + setItems(items.map(item => { + if(item.id && item.id.toHex() in callMap) { + const submissionId = item.id.toHex(); + const error = errors[submissionId]; + return { + ...item, + submitted: true, + errorType: submissionId in errors ? "chain" : undefined, + error: error ? String(error) : undefined, + success: !error, + }; + } else { + return item; + } + })); + } + clearSubmissionState(); + }, [ itemToSubmit, callBatch, callMap, items, submitCallBatch, clearSubmissionState ]); + + const batchFees = useCallback(async () => { + let total = new Fees({ inclusionFee: 0n }); + for(const itemId of Object.keys(callMap)) { + const item = items.find(item => item.id?.toHex() === itemId); + if(item) { + const fees = await itemFees(item); + total = addFees(total, fees); + } + } + return total; + }, [ callMap, itemFees, items ]); const importAll = useCallback(async () => { if(items.length > 0) { - setIsBatchImport(true); - const firstItemIndex = getFirstItem(items); - setCurrentItem(firstItemIndex); - await submitItem(items[firstItemIndex]); + setItemToSubmit(undefined); + setFees(await batchFees()); } - }, [ setIsBatchImport, setCurrentItem, submitItem, items ]); + }, [ setItemToSubmit, items, batchFees ]); const uploadItemFile = useCallback(async (item: Item, file: File) => { setUploading(true); @@ -207,7 +240,7 @@ export default function ImportItems() { buttonText: "Close", callback: close, buttonVariant: "primary", - disabled: isBatchImport + disabled: extrinsicSubmissionState.inProgress, } ]} show={ showImportItems } @@ -233,7 +266,7 @@ export default function ImportItems() { @@ -262,7 +295,7 @@ export default function ImportItems() { render: item => ( <> { - (!item.submitted && !item.error) && + item.id && (!item.submitted && !item.error) && !extrinsicSubmissionState.inProgress && } { - (item.submitted && !item.success && submitters[item.id?.toHex() || ""] !== undefined && submitters[item.id?.toHex() || ""] !== null) && + item.id && extrinsicSubmissionState.isInProgress(item.id.toHex()) && submitNext(item, true) } - onSuccess={ () => submitNext(item, false) } + } /> @@ -338,7 +369,7 @@ export default function ImportItems() { @@ -382,19 +413,6 @@ export default function ImportItems() { ); } -function getFirstItem(items: Item[]): number { - return getNextItem(items, -1); -} - - -function getNextItem(items: Item[], current: number): number { - let next = current + 1; - while(next < items.length && (items[next].error || items[next].submitted)) { - ++next; - } - return next; -} - function getNotSubmitted(items: Item[]): number { let count = 0; for(const item of items) { @@ -542,3 +560,34 @@ function validateTermsAndConditions(csvItem: CsvItem): TCValidationResult { return { tcError: "" + error } } } + +function addFees(total: Fees, term: Fees): Fees { + const inclusionFee = total.inclusionFee + term.inclusionFee; + const storageFee = addFeesTerms(total.storageFee, term.storageFee); + const legalFee = addFeesTerms(total.legalFee, term.legalFee); + const certificateFee = addFeesTerms(total.certificateFee, term.certificateFee); + const valueFee = addFeesTerms(total.valueFee, term.valueFee); + const collectionItemFee = addFeesTerms(total.collectionItemFee, term.collectionItemFee); + const tokensRecordFee = addFeesTerms(total.tokensRecordFee, term.tokensRecordFee); + return new Fees({ + inclusionFee, + storageFee, + legalFee, + certificateFee, + valueFee, + collectionItemFee, + tokensRecordFee, + }); +} + +function addFeesTerms(term1?: bigint, term2?: bigint): bigint | undefined { + if(term1 !== undefined && term2 !== undefined) { + return term1 + term2; + } else if(term1 !== undefined && term2 === undefined) { + return term1; + } else if(term1 === undefined && term2 !== undefined) { + return term2; + } else { + return undefined; + } +} diff --git a/src/loc/LocItem.tsx b/src/loc/LocItem.tsx index c19ea396..d379a5cf 100644 --- a/src/loc/LocItem.tsx +++ b/src/loc/LocItem.tsx @@ -243,10 +243,8 @@ export type LocItem = LinkItem | MetadataItem | FileItem; export enum PublishStatus { NONE, START, - PUBLISH_PENDING, PUBLISHING, - PUBLISHED, - ERROR + DONE } export interface PublishState { diff --git a/src/loc/LocPublishButton.test.tsx b/src/loc/LocPublishButton.test.tsx index 997237da..5aac5818 100644 --- a/src/loc/LocPublishButton.test.tsx +++ b/src/loc/LocPublishButton.test.tsx @@ -33,7 +33,7 @@ describe("LocPublishButton", () => { } ); const confirm = jest.fn(); - setExtrinsicSubmissionState(NO_SUBMISSION); + setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); render( { await clickByName("Publish"); // Then item published - await waitFor(() => expectSubmitting()); + await waitFor(() => screen.getByText("LOC item successfully published")); expect(confirm).toBeCalled(); }); diff --git a/src/loc/LocPublishButton.tsx b/src/loc/LocPublishButton.tsx index 2f236c2f..2b18ed0a 100644 --- a/src/loc/LocPublishButton.tsx +++ b/src/loc/LocPublishButton.tsx @@ -1,32 +1,37 @@ import { Fees } from "@logion/node-api"; import Button from "../common/Button"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import ProcessStep from "../common/ProcessStep"; -import Alert from "../common/Alert"; import { PublishProps, PublishState, PublishStatus, LinkItem, FileItem, MetadataItem } from "./LocItem"; import Icon from "../common/Icon"; import { useLocContext } from "./LocContext"; -import ClientExtrinsicSubmitter, { Call, CallCallback } from "src/ClientExtrinsicSubmitter"; import LocPublicDataDetails from "./LocPublicDataDetails"; import LocPrivateFileDetails from "./LocPrivateFileDetails"; import LocLinkDetails from "./LocLinkDetails"; import LocItemEstimatedFees from "./LocItemEstimatedFees"; +import { CallCallback, useLogionChain } from "src/logion-chain"; +import ExtrinsicSubmissionStateView from "src/ExtrinsicSubmissionStateView"; export default function LocPublishButton(props: PublishProps) { const [ publishState, setPublishState ] = useState({ status: PublishStatus.NONE }); - const [ call, setCall ] = useState(); const { mutateLocState, loc } = useLocContext(); const [ fees, setFees ] = useState(); + const { submitCall, clearSubmissionState } = useLogionChain(); - useEffect(() => { - if (publishState.status === PublishStatus.PUBLISH_PENDING) { - setPublishState({ status: PublishStatus.PUBLISHING }); - const call: Call = async (callback: CallCallback) => - mutateLocState(async current => - props.publishMutator(current, callback)); - setCall(() => call); + const call = useMemo(() => { + return async (callback: CallCallback) => + mutateLocState(async current => + props.publishMutator(current, callback)); + }, [ mutateLocState, props ]); + + const publish = useCallback(async () => { + setPublishState({ status: PublishStatus.PUBLISHING }); + try { + await submitCall(call); + } finally { + setPublishState({ status: PublishStatus.DONE }); } - }, [ props, publishState, setPublishState, mutateLocState ]); + }, [ submitCall, call ]); useEffect(() => { if(fees === undefined) { @@ -37,6 +42,11 @@ export default function LocPublishButton(props: PublishProps) { } }, [ fees, props ]); + const cancel = useCallback(async () => { + clearSubmissionState(); + setPublishState({ status: PublishStatus.NONE }); + }, [ clearSubmissionState ]); + return ( <> setPublishState({ status: PublishStatus.NONE }) + callback: cancel, }, { buttonText: 'Publish', buttonVariant: 'polkadot', id: 'publish', mayProceed: publishState.status === PublishStatus.START, - callback: () => setPublishState({ status: PublishStatus.PUBLISH_PENDING }) + callback: publish, } ]} > @@ -87,44 +97,21 @@ export default function LocPublishButton(props: PublishProps) { /> - { - setPublishState({ status: PublishStatus.PUBLISHED }) - } } - onError={ () => setPublishState({ status: PublishStatus.ERROR }) } - /> - - setPublishState({ status: PublishStatus.NONE }) + mayProceed: publishState.status === PublishStatus.DONE, + callback: cancel, } ]} > - { - publishState.status === PublishStatus.PUBLISHED && - - LOC {props.itemType} successfully published - - } - { - publishState.status === PublishStatus.ERROR && - - Failed to publish {props.itemType} - - } + ) diff --git a/src/loc/OpenLoc.tsx b/src/loc/OpenLoc.tsx index e0736514..2fa8eff5 100644 --- a/src/loc/OpenLoc.tsx +++ b/src/loc/OpenLoc.tsx @@ -1,13 +1,12 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Fees } from '@logion/node-api'; import { LocData } from '@logion/client'; -import { useLogionChain } from '../logion-chain'; +import { CallCallback, useLogionChain } from '../logion-chain'; import { useCommonContext } from '../common/CommonContext'; import ProcessStep from '../common/ProcessStep'; import CollectionLocMessage from '../loc/CollectionLocMessage'; import CollectionLimitsForm, { CollectionLimits, DEFAULT_LIMITS } from '../loc/CollectionLimitsForm'; import { useLocContext } from 'src/loc/LocContext'; -import { CallCallback } from '../ClientExtrinsicSubmitter'; import EstimatedFees, { getOtherFeesPaidBy, PAID_BY_REQUESTER } from './fees/EstimatedFees'; import { AcceptedRequest } from "@logion/client/dist/Loc"; import Button from 'src/common/Button'; diff --git a/src/loc/RequestVoteButton.test.tsx b/src/loc/RequestVoteButton.test.tsx index 58a9f8e6..9ad3bce3 100644 --- a/src/loc/RequestVoteButton.test.tsx +++ b/src/loc/RequestVoteButton.test.tsx @@ -1,7 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import { clickByName } from "src/tests"; import RequestVoteButton from "./RequestVoteButton"; -import { mockSubmittableResult } from "src/logion-chain/__mocks__/SignatureMock"; import { ClosedLoc } from "src/__mocks__/@logion/client"; import { setLocState } from "./__mocks__/LocContextMock"; import { FAILED_SUBMISSION, NO_SUBMISSION, SUCCESSFUL_SUBMISSION, setExtrinsicSubmissionState } from "src/logion-chain/__mocks__/LogionChainMock"; @@ -16,7 +15,6 @@ describe("RequestVoteButton", () => { const locState = new ClosedLoc(); setLocState(locState); locState.legalOfficer.requestVote = async (params: any) => { - params.callback(mockSubmittableResult(true)); return VOTE_ID; }; setExtrinsicSubmissionState(NO_SUBMISSION); @@ -30,7 +28,6 @@ describe("RequestVoteButton", () => { const locState = new ClosedLoc(); setLocState(locState); locState.legalOfficer.requestVote = async (params: any) => { - params.callback(mockSubmittableResult(true)); return VOTE_ID; }; setExtrinsicSubmissionState(SUCCESSFUL_SUBMISSION); @@ -44,10 +41,7 @@ describe("RequestVoteButton", () => { it("shows error on failure", async () => { const locState = new ClosedLoc(); setLocState(locState); - locState.legalOfficer.requestVote = async (params: any) => { - params.callback(mockSubmittableResult(false, "ExtrinsicFailed", true)); - throw new Error(); - }; + locState.legalOfficer.requestVote = async () => {}; setExtrinsicSubmissionState(FAILED_SUBMISSION); render(); await clickByName((_, element) => /Request a vote/.test(element.textContent || "")); diff --git a/src/logion-chain/LogionChainContext.tsx b/src/logion-chain/LogionChainContext.tsx index 5608d431..2d0bef71 100644 --- a/src/logion-chain/LogionChainContext.tsx +++ b/src/logion-chain/LogionChainContext.tsx @@ -51,93 +51,183 @@ export type Call = (callback: CallCallback) => Promise; export type SignAndSubmit = ((setResult: (result: ISubmittableResult | null) => void, setError: (error: unknown) => void) => void) | null; +export type CallBatchJobs = { submissionId: string, call: Call }[]; + +export class CallBatch { + + static readonly DEFAULT_SUBMISSION_ID = "__DEFAULT__"; + + static fromSingle(call: Call) { + return CallBatch.fromSingleWithId(CallBatch.DEFAULT_SUBMISSION_ID, call); + } + + static fromSingleWithId(submissionId: string, call: Call) { + return new CallBatch([ { submissionId, call } ]); + } + + constructor(jobs: CallBatchJobs) { + this.ensureIdUnicity(jobs); + this.jobs = jobs; + } + + private ensureIdUnicity(jobs: CallBatchJobs) { + const set = new Set(); + jobs.forEach(job => set.add(job.submissionId)); + if(set.size !== jobs.length) { + throw new Error("Unicity constraint on submission IDs is not met"); + } + } + + readonly jobs: CallBatchJobs; +}; + export class ExtrinsicSubmissionState { constructor() { - this._result = null; - this._error = null; - this._submitted = false; - this._callEnded = false; + this._result = {}; + this._error = {}; + this._submitted = {}; + this._callEnded = {}; + this._submissions = 0; } - private _result: ISubmittableResult | null; - private _error: any; - private _submitted: boolean; - private _callEnded: boolean; + private _result: Record; + private _error: Record; + private _submitted: Record; + private _callEnded: Record; + private _submissions: number; canSubmit() { return !this.submitted || this.callEnded; } get submitted() { - return this._submitted; + return Object.keys(this._submitted).length > 0; + } + + get callEnded() { // All submissions completed + return Object.keys(this._callEnded).length === this._submissions; } - get callEnded() { - return this._callEnded; + get inProgress() { + return this.submitted && !this.callEnded; } - get result() { - return this._result; + get result() { // Convenience property for single job submissions + return this.getResult(); } - get error() { - return this._error; + getResult(submissionId?: string) { + return this._result[submissionId || CallBatch.DEFAULT_SUBMISSION_ID] || null; } - submit() { + get error() { // Convenience property for single job submissions + return this.getError(); + } + + getError(submissionId?: string) { + return this._error[submissionId || CallBatch.DEFAULT_SUBMISSION_ID] || null; + } + + isInProgress(submissionId?: string) { + return this.isSubmitted(submissionId) && !this.hasEnded(submissionId); + } + + isSubmitted(submissionId?: string) { + return this._submitted[submissionId || CallBatch.DEFAULT_SUBMISSION_ID] || false; + } + + hasEnded(submissionId?: string) { + return this._callEnded[submissionId || CallBatch.DEFAULT_SUBMISSION_ID] || false; + } + + submit(submissions: number) { + if(!submissions) { + throw new Error("Cannot submit nothing"); + } if(!this.canSubmit()) { throw new Error("Cannot submit new call"); } const state = new ExtrinsicSubmissionState(); - state._result = null; - state._error = null; - state._submitted = true; - state._callEnded = false; + state._result = {}; + state._error = {}; + state._submitted = {}; + state._callEnded = {}; + state._submissions = submissions; + return state; + } + + start(submissionId: string) { + if(this._submitted[submissionId]) { + throw new Error("Call already started"); + } + const state = new ExtrinsicSubmissionState(); + state._submitted = { + ...this._submitted, + [ submissionId ]: true, + }; + state._result = this._result; + state._error = this._error; + state._callEnded = this._callEnded; + state._submissions = this._submissions; return state; } - withResult(result: ISubmittableResult) { - if(this._callEnded) { + withResult(callId: string, result: ISubmittableResult) { + if(this._callEnded[callId]) { throw new Error("Call already ended"); } const state = new ExtrinsicSubmissionState(); - state._result = result; + state._result = { + ...this._result, + [ callId ]: result, + }; state._error = this._error; state._submitted = this._submitted; state._callEnded = this._callEnded; + state._submissions = this._submissions; return state; } - end(error: unknown) { - if(this._callEnded) { + end(submissionId: string, error: unknown) { + if(this._callEnded[submissionId]) { throw new Error("Call already ended"); } const state = new ExtrinsicSubmissionState(); state._result = this._result; - state._error = error; + state._error = { + ...this._error, + [ submissionId ]: error, + } state._submitted = this._submitted; - state._callEnded = true; + state._callEnded = { + ...this._callEnded, + [ submissionId ]: true, + }; + state._submissions = this._submissions; return state; } - isSuccessful(): boolean { - return this.callEnded && this.error === null; + isSuccessful(submissionId?: string): boolean { + const error = this.error[submissionId || CallBatch.DEFAULT_SUBMISSION_ID]; + return this._callEnded[submissionId || CallBatch.DEFAULT_SUBMISSION_ID] && error === undefined; } - isError(): boolean { - return this.callEnded && this.error !== null; + isError(submissionId?: string): boolean { + const error = this.error[submissionId || CallBatch.DEFAULT_SUBMISSION_ID]; + return this._callEnded[submissionId || CallBatch.DEFAULT_SUBMISSION_ID] && error !== undefined; } resetAndKeepResult() { - if(!this._callEnded) { + if(!this.callEnded) { throw new Error("Call not yet ended"); } const state = new ExtrinsicSubmissionState(); state._result = this._result; state._error = this._error; - state._submitted = false; - state._callEnded = false; + state._submitted = {}; + state._callEnded = {}; + state._submissions = 0; return state; } } @@ -166,6 +256,7 @@ export interface LogionChainContextType { tryEnableMetaMask: () => Promise, extrinsicSubmissionState: ExtrinsicSubmissionState; submitCall: (call: Call) => Promise; + submitCallBatch: (batch: CallBatch) => Promise>; submitSignAndSubmit: (signAndSubmit: SignAndSubmit) => void; resetSubmissionState: () => void; clearSubmissionState: () => void; @@ -200,6 +291,7 @@ const initState = (): FullLogionChainContextType => ({ tryEnableMetaMask: async () => {}, extrinsicSubmissionState: new ExtrinsicSubmissionState(), submitCall: () => Promise.reject(), + submitCallBatch: () => Promise.reject(), submitSignAndSubmit: () => {}, resetSubmissionState: () => {}, clearSubmissionState: () => {}, @@ -234,6 +326,8 @@ type ActionType = 'SET_SELECT_ADDRESS' | 'RESET_SUBMISSION_STATE' | 'SET_CLEAR_SUBMISSION_STATE' | 'CLEAR_SUBMISSION_STATE' + | 'SET_SUBMIT_CALL_BATCH' + | 'SET_SUBMISSION_STARTED' ; interface Action { @@ -263,6 +357,9 @@ interface Action { submitSignAndSubmit?: (signAndSubmit: SignAndSubmit) => void; resetSubmissionState?: () => void; clearSubmissionState?: () => void; + submissions?: number; + submissionId?: string; + submitCallBatch?: (batch: CallBatch) => Promise>; } function buildAxiosFactory(authenticatedClient?: LogionClient): AxiosFactory { @@ -447,20 +544,25 @@ const reducer: Reducer = (state: FullLogionC if(state.extrinsicSubmissionState.canSubmit()) { return { ...state, - extrinsicSubmissionState: state.extrinsicSubmissionState.submit(), + extrinsicSubmissionState: state.extrinsicSubmissionState.submit(action.submissions!), }; } else { return state; } + case 'SET_SUBMISSION_STARTED': + return { + ...state, + extrinsicSubmissionState: state.extrinsicSubmissionState.start(action.submissionId!), + }; case 'SET_SUBMITTABLE_RESULT': return { ...state, - extrinsicSubmissionState: state.extrinsicSubmissionState.withResult(action.result!), + extrinsicSubmissionState: state.extrinsicSubmissionState.withResult(action.submissionId!, action.result!), }; case 'SET_SUBMISSION_ENDED': return { ...state, - extrinsicSubmissionState: state.extrinsicSubmissionState.end(action.extrinsicSubmissionError || null), + extrinsicSubmissionState: state.extrinsicSubmissionState.end(action.submissionId!, action.extrinsicSubmissionError || null), }; case 'SET_SUBMIT_SIGN_AND_SUBMIT': return { @@ -487,6 +589,11 @@ const reducer: Reducer = (state: FullLogionC ...state, extrinsicSubmissionState: new ExtrinsicSubmissionState(), }; + case 'SET_SUBMIT_CALL_BATCH': + return { + ...state, + submitCallBatch: action.submitCallBatch!, + }; default: /* istanbul ignore next */ throw new Error(`Unknown type: ${action.type}`); @@ -778,29 +885,64 @@ const LogionChainContextProvider = (props: LogionChainContextProviderProps): JSX } }, [ state.tryEnableMetaMask, tryEnableMetaMask ]); - const submitCall = useCallback(async (call: Call) => { + const submitCallBatch = useCallback(async (batch: CallBatch) => { if(state.extrinsicSubmissionState.canSubmit()) { dispatch({ type: 'SUBMIT_EXTRINSIC', + submissions: batch.jobs.length, }); - (async function() { + let errorMap: Record = {}; + for(const job of batch.jobs) { try { - await call((callbackResult: ISubmittableResult) => dispatch({ + dispatch({ + type: 'SET_SUBMISSION_STARTED', + submissionId: job.submissionId, + }); + await job.call((callbackResult: ISubmittableResult) => dispatch({ type: 'SET_SUBMITTABLE_RESULT', result: callbackResult, + submissionId: job.submissionId, })); - dispatch({ type: 'SET_SUBMISSION_ENDED' }); + dispatch({ + type: 'SET_SUBMISSION_ENDED', + submissionId: job.submissionId, + }); } catch(e) { console.log(e); dispatch({ type: 'SET_SUBMISSION_ENDED', extrinsicSubmissionError: e, + submissionId: job.submissionId, }); + errorMap[job.submissionId] = e; } - })(); + } + return errorMap; + } else { + let errorMap: Record = {}; + for(const job of batch.jobs) { + errorMap[job.submissionId] = "Could not submit"; + } + return errorMap; } }, [ state.extrinsicSubmissionState ]); + useEffect(() => { + if(state.submitCallBatch !== submitCallBatch) { + dispatch({ + type: 'SET_SUBMIT_CALL_BATCH', + submitCallBatch, + }); + } + }, [ state.submitCallBatch, submitCallBatch ]); + + const submitCall = useCallback(async (call: Call) => { + const errors = await submitCallBatch(CallBatch.fromSingle(call)); + if(errors[CallBatch.DEFAULT_SUBMISSION_ID]) { + throw errors[CallBatch.DEFAULT_SUBMISSION_ID]; + } + }, [ submitCallBatch ]); + useEffect(() => { if(state.submitCall !== submitCall) { dispatch({ diff --git a/src/logion-chain/__mocks__/LogionChainMock.ts b/src/logion-chain/__mocks__/LogionChainMock.ts index a15ce8a6..e2437247 100644 --- a/src/logion-chain/__mocks__/LogionChainMock.ts +++ b/src/logion-chain/__mocks__/LogionChainMock.ts @@ -8,7 +8,7 @@ import Accounts, { Account } from 'src/common/types/Accounts'; import { LogionClient } from '@logion/client/dist/LogionClient.js'; import { LogionClient as LogionClientMock } from '../../__mocks__/LogionClientMock'; import { api } from "src/__mocks__/LogionMock"; -import { Call } from "../LogionChainContext"; +import { Call, CallBatch } from "../LogionChainContext"; export const LogionChainContextProvider = (props: any) => null; @@ -104,7 +104,14 @@ export const SUCCESSFUL_SUBMISSION: unknown = { canSubmit: () => true, isSuccessful: () => true, isError: () => false, + isInProgress: () => false, error: null, + getError: () => null, + getResult: () => ({ + status: { + isFinalized: true, + } + }), result: { status: { isFinalized: true, @@ -115,10 +122,17 @@ export const SUCCESSFUL_SUBMISSION: unknown = { }; export const FAILED_SUBMISSION: unknown = { - canSubmit: () => false, + canSubmit: () => true, isSuccessful: () => false, isError: () => true, + isInProgress: () => false, error: "error", + getError: () => "error", + getResult: () => ({ + status: { + isFinalized: false, + } + }), result: { status: { isFinalized: false, @@ -132,7 +146,14 @@ export const PENDING_SUBMISSION: unknown = { canSubmit: () => false, isSuccessful: () => false, isError: () => false, + isInProgress: () => true, error: null, + getError: () => null, + getResult: () => ({ + status: { + isFinalized: false, + } + }), result: { status: { isFinalized: false, @@ -173,6 +194,7 @@ export function useLogionChain() { resetSubmissionState: () => {}, submitSignAndSubmit: () => {}, submitCall: (call: Call) => call(() => {}), + submitCallBatch: (batch: CallBatch) => { batch.jobs.forEach(job => job.call(() => {})); return {}; }, clearSubmissionState: () => {}, }; }