diff --git a/server/src/controllers/common.ts b/server/src/controllers/common.ts index b2ee714..57771d6 100644 --- a/server/src/controllers/common.ts +++ b/server/src/controllers/common.ts @@ -1,4 +1,4 @@ -import { Get, JsonController, Param, QueryParam } from 'routing-controllers'; +import { Get, InternalServerError, JsonController, Param, QueryParam } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { firefly } from '../clients/firefly'; import { FFStatus, Organization, Plugin, Plugins, Transaction, Verifier } from '../interfaces'; @@ -33,19 +33,27 @@ export class CommonController { @ResponseSchema(Verifier, { isArray: true }) @OpenAPI({ summary: 'List verifiers (such as Ethereum keys) for all organizations in network' }) async verifiers(): Promise { - const orgs = await firefly.getOrganizations(); - const defaultVerifiers = await firefly.getVerifiers('default'); - const legacyVerifiers = await firefly.getVerifiers('ff_system'); - const verifiers = defaultVerifiers.concat(legacyVerifiers); - - const result: Verifier[] = []; - for (const v of verifiers) { - const o = orgs.find((o) => o.id === v.identity); - if (o !== undefined) { - result.push({ did: o.did, type: v.type, value: v.value }); + try { + const orgs = await firefly.getOrganizations(); + let verifiers = await firefly.getVerifiers('default'); + if (verifiers.length === 0) { + // attempt to query legacy ff_system verifiers + verifiers = await firefly.getVerifiers('ff_system'); } + const result: Verifier[] = []; + for (const v of verifiers) { + const o = orgs.find((o) => o.id === v.identity); + if (o !== undefined) { + result.push({ did: o.did, type: v.type, value: v.value }); + } + } + return result; + } catch (err) { + if (err.message == "FF10187: Namespace does not exist") { + return []; + } + throw new InternalServerError(err.message); } - return result; } @Get('/verifiers/self') @@ -92,7 +100,7 @@ export class CommonController { const status = await firefly.getStatus(); if ("multiparty" in status) { return { - multiparty: status.multiparty.enabled, + multiparty: status.multiparty?.enabled, }; } else { // Assume multiparty mode if `multiparty` key is missing from status diff --git a/server/src/controllers/contracts.ts b/server/src/controllers/contracts.ts index 2a6f900..c92e346 100644 --- a/server/src/controllers/contracts.ts +++ b/server/src/controllers/contracts.ts @@ -152,6 +152,9 @@ export class ContractsController { const listener = await firefly.createContractAPIListener(body.apiName, body.eventPath, { topic: body.topic, name: body.name, + options: { + firstEvent: body.firstEvent + } }); return { id: listener.id, @@ -214,6 +217,9 @@ export class ContractsTemplateController { { topic: <%= ${q('topic')} %>,<% if (name) { %> <% print('name: ' + ${q('name')} + ',') } %> + options: {<% if (firstEvent) { %> + <% print('firstEvent: ' + ${q('firstEvent')} + ',') } %> + } }, ); return { diff --git a/server/src/controllers/tokens.ts b/server/src/controllers/tokens.ts index 33d315e..3818cfb 100644 --- a/server/src/controllers/tokens.ts +++ b/server/src/controllers/tokens.ts @@ -67,6 +67,7 @@ export class TokensController { type: body.type, config: { address: body.address, + blockNumber: body.blockNumber, }, }); return { type: 'token_pool', id: pool.id }; @@ -259,6 +260,7 @@ export class TokensTemplateController { type: <%= ${q('type')} %>, config: {<% if (address) { %> <% print('address: ' + ${q('address')} + ',') } %> + blockNumber: <%= ${q('blockNumber')} %>, } }); return { type: 'token_pool', id: pool.id }; diff --git a/server/src/interfaces.ts b/server/src/interfaces.ts index ad84109..399023f 100644 --- a/server/src/interfaces.ts +++ b/server/src/interfaces.ts @@ -40,6 +40,11 @@ export class DatatypeDefinition { version: string; } +export class FFContractListenerOptions { + @IsOptional() + firstEvent: string; +} + export class BroadcastValue extends BaseMessageFields { @IsString() @IsOptional() @@ -144,6 +149,10 @@ export class TokenPoolInput { @IsString() @IsOptional() address?: string; + + @IsString() + @IsOptional() + blockNumber?: string; } export class TokenPool extends TokenPoolInput { @@ -305,6 +314,10 @@ export class ContractListener { @IsString() eventPath: string; + + @IsOptional() + @IsString() + firstEvent?: string; } export class ContractListenerLookup { diff --git a/server/test/common.test.ts b/server/test/common.test.ts index 342358f..158c4b6 100644 --- a/server/test/common.test.ts +++ b/server/test/common.test.ts @@ -62,7 +62,6 @@ describe('Common Operations', () => { mockFireFly.getOrganizations.mockResolvedValueOnce(orgs); mockFireFly.getVerifiers.mockResolvedValueOnce(verifiers); - mockFireFly.getVerifiers.mockResolvedValueOnce([]); await request(server) .get('/api/common/verifiers') @@ -71,8 +70,5 @@ describe('Common Operations', () => { expect(mockFireFly.getOrganizations).toHaveBeenCalledWith(); expect(mockFireFly.getVerifiers).toHaveBeenCalledWith('default'); - - expect(mockFireFly.getOrganizations).toHaveBeenCalledWith(); - expect(mockFireFly.getVerifiers).toHaveBeenCalledWith('ff_system'); }); }); diff --git a/server/test/contracts.template.test.ts b/server/test/contracts.template.test.ts index f2b1eea..98c4ddd 100644 --- a/server/test/contracts.template.test.ts +++ b/server/test/contracts.template.test.ts @@ -127,6 +127,7 @@ describe('Templates: Smart Contracts', () => { topic: 'app1', apiName: 'api1', eventPath: 'set', + firstEvent: 'newest', }), ).toBe( formatTemplate(` @@ -135,6 +136,9 @@ describe('Templates: Smart Contracts', () => { 'set', { topic: 'app1', + options: { + firstEvent: 'newest', + } }, ); return { diff --git a/server/test/contracts.test.ts b/server/test/contracts.test.ts index 7e22973..6d3d8af 100644 --- a/server/test/contracts.test.ts +++ b/server/test/contracts.test.ts @@ -226,6 +226,7 @@ describe('Smart Contracts', () => { apiName: 'my-api', eventPath: 'Changed', topic: 'my-app', + firstEvent: 'newest' }; const listener = { id: 'listener1', @@ -245,6 +246,9 @@ describe('Smart Contracts', () => { expect(mockFireFly.createContractAPIListener).toHaveBeenCalledWith('my-api', 'Changed', { topic: 'my-app', + options: { + firstEvent: 'newest', + }, }); }); }); diff --git a/server/test/tokens.template.test.ts b/server/test/tokens.template.test.ts index e5df488..fc0d119 100644 --- a/server/test/tokens.template.test.ts +++ b/server/test/tokens.template.test.ts @@ -16,6 +16,7 @@ describe('Templates: Tokens', () => { symbol: 'P1', type: 'fungible', address: undefined, + blockNumber: '0', }), ).toBe( formatTemplate(` @@ -24,6 +25,7 @@ describe('Templates: Tokens', () => { symbol: 'P1', type: 'fungible', config: { + blockNumber: '0', } }); return { type: 'token_pool', id: pool.id }; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6ad8017..f8737e9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,10 @@ import { SnackbarMessageType, } from './components/Snackbar/MessageSnackbar'; import { SDK_PATHS } from './constants/SDK_PATHS'; +import { + GatewayTutorialSections, + TutorialSections, +} from './constants/TutorialSections'; import { ApplicationContext } from './contexts/ApplicationContext'; import { SnackbarContext } from './contexts/SnackbarContext'; import { @@ -19,13 +23,14 @@ import { ISelfIdentity, IVerifier, } from './interfaces/api'; +import { ITutorialSection } from './interfaces/tutorialSection'; import { themeOptions } from './theme'; import { fetchCatcher, summarizeFetchError } from './utils/fetches'; export const MAX_FORM_ROWS = 10; function App() { - const [initialized, setInitialized] = useState(true); + const [initialized, setInitialized] = useState(false); const [message, setMessage] = useState(''); const [messageType, setMessageType] = useState('error'); @@ -41,6 +46,9 @@ function App() { }); const [tokensDisabled, setTokensDisabled] = useState(false); const [blockchainPlugin, setBlockchainPlugin] = useState(''); + const [tutorialSections, setTutorialSections] = useState( + [] + ); useEffect(() => { Promise.all([ @@ -59,6 +67,7 @@ function App() { const ffStatus = statusResponse as IFireflyStatus; setMultiparty(ffStatus.multiparty); if (ffStatus.multiparty === true) { + setTutorialSections(TutorialSections); fetchCatcher(SDK_PATHS.verifiers) .then((verifierRes: IVerifier[]) => { setSelfIdentity({ @@ -71,6 +80,8 @@ function App() { .catch((err) => { reportFetchError(err); }); + } else { + setTutorialSections(GatewayTutorialSections); } }) .finally(() => { @@ -110,6 +121,7 @@ function App() { tokensDisabled, blockchainPlugin, multiparty, + tutorialSections, }} > diff --git a/ui/src/AppWrapper.tsx b/ui/src/AppWrapper.tsx index 7af5c2f..15c5214 100644 --- a/ui/src/AppWrapper.tsx +++ b/ui/src/AppWrapper.tsx @@ -40,11 +40,17 @@ export const DEFAULT_ACTION = [ TUTORIAL_FORMS.BROADCAST, ]; +export const DEFAULT_GATEWAY_ACTION = [ + TUTORIAL_CATEGORIES.TOKENS, + TUTORIAL_FORMS.POOL, +]; + export const AppWrapper: React.FC = () => { const { pathname, search } = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(); - const { setPayloadMissingFields } = useContext(ApplicationContext); + const { setPayloadMissingFields, multiparty, tutorialSections } = + useContext(ApplicationContext); const [action, setAction] = useState(null); const [categoryID, setCategoryID] = useState(undefined); const [formID, setFormID] = useState(undefined); @@ -64,7 +70,7 @@ export const AppWrapper: React.FC = () => { useEffect(() => { initializeFocusedForm(); - }, [pathname, search]); + }, [pathname, search, tutorialSections]); // Set form object based on action useEffect(() => { @@ -84,11 +90,16 @@ export const AppWrapper: React.FC = () => { const initializeFocusedForm = () => { const existingAction = searchParams.get(ACTION_QUERY_KEY); - if (existingAction === null) { - setCategoryID(DEFAULT_ACTION[0]); - setFormID(DEFAULT_ACTION[1]); - setActionParam(DEFAULT_ACTION[0], DEFAULT_ACTION[1]); + if (multiparty) { + setCategoryID(DEFAULT_ACTION[0]); + setFormID(DEFAULT_ACTION[1]); + setActionParam(DEFAULT_ACTION[0], DEFAULT_ACTION[1]); + } else { + setCategoryID(DEFAULT_GATEWAY_ACTION[0]); + setFormID(DEFAULT_GATEWAY_ACTION[1]); + setActionParam(DEFAULT_GATEWAY_ACTION[0], DEFAULT_GATEWAY_ACTION[1]); + } } else { const validAction: string[] = getValidAction(existingAction); setCategoryID(validAction[0]); @@ -134,7 +145,11 @@ export const AppWrapper: React.FC = () => { const getValidAction = (action: string) => { if (!isValidAction(action)) { - return DEFAULT_ACTION; + if (multiparty) { + return DEFAULT_ACTION; + } else { + return DEFAULT_GATEWAY_ACTION; + } } return action.split(ACTION_DELIM); diff --git a/ui/src/components/Forms/Contracts/RegisterContractApiListenerForm.tsx b/ui/src/components/Forms/Contracts/RegisterContractApiListenerForm.tsx index 1b28a4e..deec745 100644 --- a/ui/src/components/Forms/Contracts/RegisterContractApiListenerForm.tsx +++ b/ui/src/components/Forms/Contracts/RegisterContractApiListenerForm.tsx @@ -29,6 +29,7 @@ export const RegisterContractApiListenerForm: React.FC = () => { const [contractApi, setContractApi] = useState(''); const [events, setEvents] = useState([]); const [eventPath, setEventPath] = useState(''); + const [firstEvent, setFirstEvent] = useState('newest'); const [name, setName] = useState(''); const [topic, setTopic] = useState(''); @@ -52,8 +53,9 @@ export const RegisterContractApiListenerForm: React.FC = () => { topic, apiName: contractApi, eventPath, + firstEvent, }); - }, [name, topic, contractApi, eventPath, formID]); + }, [name, topic, contractApi, eventPath, formID, firstEvent]); useEffect(() => { isMounted && @@ -157,6 +159,17 @@ export const RegisterContractApiListenerForm: React.FC = () => { /> + + + setFirstEvent(e.target.value)} + helperText={t('firstEventDescription')} + value={firstEvent} + /> + + ); diff --git a/ui/src/components/Forms/Tokens/PoolForm.tsx b/ui/src/components/Forms/Tokens/PoolForm.tsx index db8ccb6..f052239 100644 --- a/ui/src/components/Forms/Tokens/PoolForm.tsx +++ b/ui/src/components/Forms/Tokens/PoolForm.tsx @@ -6,7 +6,7 @@ import { Select, TextField, } from '@mui/material'; -import { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TUTORIAL_FORMS } from '../../../constants/TutorialSections'; import { ApplicationContext } from '../../../contexts/ApplicationContext'; @@ -21,6 +21,7 @@ export const PoolForm: React.FC = () => { const [name, setName] = useState(''); const [symbol, setSymbol] = useState(''); const [address, setAddress] = useState(); + const [blockNumber, setBlockNumber] = useState('0'); const [type, setType] = useState<'fungible' | 'nonfungible'>('fungible'); useEffect(() => { @@ -36,8 +37,9 @@ export const PoolForm: React.FC = () => { symbol: symbol === '' ? null : symbol, type, address, + blockNumber, }); - }, [name, symbol, type, address, formID]); + }, [name, symbol, type, address, formID, blockNumber]); const handleNameChange = (event: React.ChangeEvent) => { if (event.target.value.indexOf(' ') < 0) { @@ -51,6 +53,14 @@ export const PoolForm: React.FC = () => { } }; + const handleBlockNumberChange = ( + event: React.ChangeEvent + ) => { + if (event.target.value.indexOf(' ') < 0) { + setBlockNumber(event.target.value); + } + }; + return ( @@ -107,6 +117,17 @@ export const PoolForm: React.FC = () => { /> + + + + + ); diff --git a/ui/src/constants/TutorialSections.tsx b/ui/src/constants/TutorialSections.tsx index 82cd926..5c766c3 100644 --- a/ui/src/constants/TutorialSections.tsx +++ b/ui/src/constants/TutorialSections.tsx @@ -202,3 +202,113 @@ export const TutorialSections: ITutorialSection[] = [ ], }, ]; + +export const GatewayTutorialSections: ITutorialSection[] = [ + { + category: TUTORIAL_CATEGORIES.TOKENS, + icon: , + tutorials: [ + { + formID: TUTORIAL_FORMS.POOL, + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/mint_tokens.html#create-a-pool', + endpoint: SDK_PATHS.tokensPools, + form: , + runnable: true, + shortInfo: 'poolShortInfo', + title: 'poolTitle', + icon: , + linesOfCode: 7, + }, + { + formID: TUTORIAL_FORMS.MINT, + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/mint_tokens.html', + endpoint: SDK_PATHS.tokensMint, + form: , + runnable: true, + shortInfo: 'mintShortInfo', + title: 'mintTitle', + icon: , + linesOfCode: 5, + }, + { + formID: TUTORIAL_FORMS.TRANSFER, + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/mint_tokens.html#transfer-tokens', + endpoint: SDK_PATHS.tokensTransfer, + form: , + runnable: true, + shortInfo: 'transferShortInfo', + title: 'transferTitle', + icon: , + linesOfCode: 7, + }, + { + formID: TUTORIAL_FORMS.BURN, + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/mint_tokens.html#burn-tokens', + endpoint: SDK_PATHS.tokensBurn, + form: , + runnable: true, + shortInfo: 'burnShortInfo', + title: 'burnTitle', + icon: , + linesOfCode: 6, + }, + ], + }, + { + category: TUTORIAL_CATEGORIES.CONTRACTS, + icon: , + tutorials: [ + { + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/custom_contracts.html#contract-deployment', + form: , + formID: TUTORIAL_FORMS.DEPLOY_CONTRACT, + shortInfo: 'deployContractInfo', + title: 'deployContractTitle', + runnable: false, + icon: , + linesOfCode: 3, + }, + { + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/custom_contracts.html#broadcast-the-contract-interface', + endpoint: SDK_PATHS.contractsInterface, + form: , + formID: TUTORIAL_FORMS.DEFINE_CONTRACT_INTERFACE, + shortInfo: 'defineContractInterfaceInfo', + runnable: true, + title: 'contractInterfaceTitle', + icon: , + linesOfCode: 3, + }, + { + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/custom_contracts.html#create-an-http-api-for-the-contract', + endpoint: SDK_PATHS.contractsApi, + form: , + formID: TUTORIAL_FORMS.REGISTER_CONTRACT_API, + shortInfo: 'registerContractApiInfo', + runnable: true, + title: 'registerContractApiTitle', + icon: , + linesOfCode: 11, + }, + { + docsURL: + 'https://hyperledger.github.io/firefly/gettingstarted/custom_contracts.html#create-a-blockchain-event-listener', + endpoint: SDK_PATHS.contractsListener, + form: , + formID: TUTORIAL_FORMS.REGISTER_CONTRACT_API_LISTENER, + shortInfo: 'registerContractApiListenerInfo', + runnable: true, + title: 'registerApiListenerTitle', + icon: , + linesOfCode: 12, + }, + ], + }, +]; diff --git a/ui/src/contexts/ApplicationContext.tsx b/ui/src/contexts/ApplicationContext.tsx index 41d533f..15c2694 100644 --- a/ui/src/contexts/ApplicationContext.tsx +++ b/ui/src/contexts/ApplicationContext.tsx @@ -16,6 +16,7 @@ import { createContext, Dispatch, SetStateAction } from 'react'; import { IApiStatus, ISelfIdentity } from '../interfaces/api'; +import { ITutorialSection } from '../interfaces/tutorialSection'; export interface IApplicationContext { selfIdentity: ISelfIdentity | undefined; @@ -34,6 +35,7 @@ export interface IApplicationContext { tokensDisabled: boolean; blockchainPlugin: string; multiparty: boolean; + tutorialSections: ITutorialSection[]; } export const ApplicationContext = createContext({} as IApplicationContext); diff --git a/ui/src/pages/Home/views/LeftPane.tsx b/ui/src/pages/Home/views/LeftPane.tsx index 304c514..dec1a23 100644 --- a/ui/src/pages/Home/views/LeftPane.tsx +++ b/ui/src/pages/Home/views/LeftPane.tsx @@ -15,32 +15,47 @@ import { ContractStateBox } from '../../../components/Boxes/ContractStateBox'; import { FFAccordionHeader } from '../../../components/Accordion/FFAccordionHeader'; import { FFAccordionText } from '../../../components/Accordion/FFAccordionText'; import { TokenStateBox } from '../../../components/Boxes/TokenStateBox'; -import { - TutorialSections, - TUTORIAL_CATEGORIES, -} from '../../../constants/TutorialSections'; +import { TUTORIAL_CATEGORIES } from '../../../constants/TutorialSections'; import { ApplicationContext } from '../../../contexts/ApplicationContext'; import { FormContext } from '../../../contexts/FormContext'; import { DEFAULT_PADDING } from '../../../theme'; -const currentStateMap: { [idx: number]: JSX.Element | undefined } = { +const multipartyStateMap: { [idx: number]: JSX.Element | undefined } = { 0: undefined, 1: , 2: , }; +const gatewayStateMap: { [idx: number]: JSX.Element | undefined } = { + 0: , + 1: , +}; + export const LeftPane = () => { const { t } = useTranslation(); - const { tokensDisabled, multiparty } = useContext(ApplicationContext); + const { tokensDisabled, multiparty, tutorialSections } = + useContext(ApplicationContext); const { formID, categoryID, setActionParam, setPoolObject } = useContext(FormContext); const [tabIdx, setTabIdx] = useState(0); - const [tutorials, setTutorials] = useState(TutorialSections); + const [currentStateMap, setCurrentStateMap] = useState<{ + [idx: number]: JSX.Element | undefined; + }>({}); + + useEffect(() => { + if (multiparty) { + setCurrentStateMap(multipartyStateMap); + } else { + setCurrentStateMap(gatewayStateMap); + } + }, [multiparty]); // Set tab index when category ID changes useEffect(() => { if (formID && categoryID) { - const tabIdx = tutorials.findIndex((t) => t.category === categoryID); + const tabIdx = tutorialSections.findIndex( + (t) => t.category === categoryID + ); if (tabIdx === -1) { // Category not found, set to default setActionParam(DEFAULT_ACTION[0], DEFAULT_ACTION[1]); @@ -51,20 +66,10 @@ export const LeftPane = () => { } }, [formID, categoryID]); - useEffect(() => { - if (!multiparty) { - setTutorials( - tutorials.filter( - (section) => section.category !== TUTORIAL_CATEGORIES.MESSAGES - ) - ); - } - }, [multiparty]); - const handleTabChange = (_: React.SyntheticEvent, newTabIdx: number) => { setPoolObject(undefined); - const selectedTutorial = tutorials.find( - (t) => t.category === tutorials[newTabIdx].category + const selectedTutorial = tutorialSections.find( + (t) => t.category === tutorialSections[newTabIdx].category ); if (selectedTutorial) { setActionParam( @@ -84,7 +89,7 @@ export const LeftPane = () => { value={tabIdx ?? 0} onChange={handleTabChange} > - {tutorials.map((section) => { + {tutorialSections.map((section) => { return ( { {currentStateMap[tabIdx]} )} {/* Tutorial section column */} - {tutorials + {tutorialSections .filter( - (section) => section.category === tutorials[tabIdx].category + (section) => + section.category === tutorialSections[tabIdx].category ) .map((ts) => { return ( diff --git a/ui/src/translations/en.json b/ui/src/translations/en.json index 63b00f3..b15ef23 100644 --- a/ui/src/translations/en.json +++ b/ui/src/translations/en.json @@ -22,6 +22,8 @@ "blockchainEventReceived": "Blockchain Event Received", "blockchainInvokeFailed": "Blockchain Invoke Failed", "blockchainInvokeSucceeded": "Blockchain Invoke Succeeded", + "blockNumber": "Block Number", + "blockNumberDescription": "The block number to subscribe from.", "broadcast": "Broadcast", "broadcastShortInfo": "Sends a message visible to all parties in the network", "broadcastTitle": "Send a Broadcast Message", @@ -86,6 +88,8 @@ "filterEventSubscriptions": "Filter Event Subscriptions (Optional)", "finish": "Finish", "fireflyCurrentState": "FireFly's Current State", + "firstEvent": "First Event", + "firstEventDescription": "The first event for this listener to index. Valid options are 'newest', 'oldest', or a specific block number.", "followStepsInInstructions": "Follow steps outlined in the instructions", "format": "format", "fromAddress": "From Address", @@ -106,6 +110,7 @@ "messageID": "Message ID", "messageRejected": "Message Rejected", "messages": "Messages", + "messagesMultipartyWarning": "Messaging is currently disabled when multiparty mode is off", "messageType": "Message Type", "messagingMethod": "Messaging Method", "mint": "Mint",