From 1fc33c9d6af24c8b6b2e5fe1615901cd6ae97ea2 Mon Sep 17 00:00:00 2001 From: MantisClone Date: Fri, 30 Oct 2026 23:31:43 +0100 Subject: [PATCH 1/2] fix: update request handling --- .../src/extensions/abstract-extension.ts | 23 +++++- .../src/extensions/payment-network/meta.ts | 7 +- .../test/extensions/content-data.test.ts | 37 ++++++++- .../erc20/fee-proxy-contract.test.ts | 62 +++++++++++++++ .../extensions/payment-network/meta.test.ts | 40 ++++++++++ packages/request-logic/test/index.test.ts | 79 +++++++++++++++++++ 6 files changed, 245 insertions(+), 3 deletions(-) diff --git a/packages/advanced-logic/src/extensions/abstract-extension.ts b/packages/advanced-logic/src/extensions/abstract-extension.ts index b28bf23b3d..dd60054dd3 100644 --- a/packages/advanced-logic/src/extensions/abstract-extension.ts +++ b/packages/advanced-logic/src/extensions/abstract-extension.ts @@ -1,5 +1,5 @@ import { ExtensionTypes, IdentityTypes, RequestLogicTypes } from '@requestnetwork/types'; -import { deepCopy } from '@requestnetwork/utils'; +import { areEqualIdentities, deepCopy } from '@requestnetwork/utils'; export interface ICreationContext { extensionsState: RequestLogicTypes.IExtensionStates; @@ -67,6 +67,17 @@ export abstract class AbstractExtension implements Extensio throw Error(`This extension has already been created`); } + // New extensions can supply payment instructions or content. + // After request creation, only the payee may add them. + if (!this.isRequestCreation(requestState)) { + if (!requestState.payee) { + throw Error(`The request must have a payee`); + } + if (!areEqualIdentities(actionSigner, requestState.payee)) { + throw Error(`The signer must be the payee`); + } + } + copiedExtensionState[extensionAction.id] = this.applyCreation(extensionAction, timestamp, { extensionsState, extensionAction, @@ -141,4 +152,14 @@ export abstract class AbstractExtension implements Extensio _extensionAction: ExtensionTypes.IAction, // eslint-disable-next-line @typescript-eslint/no-empty-function ): void {} + + /** + * Whether the action being applied is part of the request creation action. + * Every request action pushes an event before extensions are applied, so the request is + * being created only if its last event is the creation event. + */ + private isRequestCreation(requestState: RequestLogicTypes.IRequest): boolean { + const lastEvent = requestState.events[requestState.events.length - 1]; + return !lastEvent || lastEvent.name === RequestLogicTypes.ACTION_NAME.CREATE; + } } diff --git a/packages/advanced-logic/src/extensions/payment-network/meta.ts b/packages/advanced-logic/src/extensions/payment-network/meta.ts index bdcb846ebd..49416a7e0c 100644 --- a/packages/advanced-logic/src/extensions/payment-network/meta.ts +++ b/packages/advanced-logic/src/extensions/payment-network/meta.ts @@ -211,7 +211,12 @@ export default class MetaPaymentNetwork< } }); } else if (extensionAction.action === ExtensionTypes.PnMeta.ACTION.APPLY_ACTION_TO_PN) { - const { pnIdentifier } = extensionAction.parameters; + const { pnIdentifier, action } = extensionAction.parameters; + + // Sub-payment networks can only be created with the meta payment network + if (action === ExtensionTypes.PnMeta.ACTION.CREATE) { + throw new Error('Invalid action'); + } const subPnState: ExtensionTypes.IState = request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.META]?.values?.[pnIdentifier]; diff --git a/packages/advanced-logic/test/extensions/content-data.test.ts b/packages/advanced-logic/test/extensions/content-data.test.ts index a23d9629d0..dae1c304df 100644 --- a/packages/advanced-logic/test/extensions/content-data.test.ts +++ b/packages/advanced-logic/test/extensions/content-data.test.ts @@ -1,4 +1,4 @@ -import { ExtensionTypes } from '@requestnetwork/types'; +import { ExtensionTypes, RequestLogicTypes } from '@requestnetwork/types'; import { deepCopy } from '@requestnetwork/utils'; import ContentData from '../../src/extensions/content-data'; @@ -29,6 +29,41 @@ describe('content-data', () => { // 'previous request state must not change' expect(requestCreatedNoExtensionBefore).toEqual(TestData.requestCreatedNoExtension); }); + describe('after the request creation', () => { + const requestAfterCreation: RequestLogicTypes.IRequest = deepCopy( + TestData.requestCreatedNoExtension, + ); + requestAfterCreation.events.push({ + actionSigner: TestData.otherIdRaw.identity, + name: RequestLogicTypes.ACTION_NAME.ADD_EXTENSIONS_DATA, + parameters: { extensionsDataLength: 1 }, + timestamp: TestData.arbitraryTimestamp, + }); + + it('can create state if signed by the payee', () => { + expect( + contentData.applyActionToExtension( + {}, + TestData.createContentDataExtensionData, + requestAfterCreation, + TestData.payeeRaw.identity, + TestData.arbitraryTimestamp, + ), + ).toEqual(TestData.expectedCreatedContentDataState); + }); + + it('cannot create state if not signed by the payee', () => { + expect(() => + contentData.applyActionToExtension( + {}, + TestData.createContentDataExtensionData, + requestAfterCreation, + TestData.otherIdRaw.identity, + TestData.arbitraryTimestamp, + ), + ).toThrowError('The signer must be the payee'); + }); + }); it('cannot create state if already state', () => { expect(() => contentData.applyActionToExtension( diff --git a/packages/advanced-logic/test/extensions/payment-network/erc20/fee-proxy-contract.test.ts b/packages/advanced-logic/test/extensions/payment-network/erc20/fee-proxy-contract.test.ts index 1fa5bcb2ed..10f5a10c2d 100644 --- a/packages/advanced-logic/test/extensions/payment-network/erc20/fee-proxy-contract.test.ts +++ b/packages/advanced-logic/test/extensions/payment-network/erc20/fee-proxy-contract.test.ts @@ -290,6 +290,68 @@ describe('extensions/payment-network/erc20/fee-proxy-contract', () => { ).toEqual(DataERC20FeeCreate.extensionFullState); }); + describe('after the request creation', () => { + const requestAfterCreation: RequestLogicTypes.IRequest = deepCopy( + DataERC20FeeCreate.requestStateNoExtensions, + ); + requestAfterCreation.events.push({ + actionSigner: TestData.otherIdRaw.identity, + name: RequestLogicTypes.ACTION_NAME.ADD_EXTENSIONS_DATA, + parameters: { extensionsDataLength: 1 }, + timestamp: TestData.arbitraryTimestamp, + }); + + it('can applyActionToExtensions of creation signed by the payee', () => { + expect( + erc20FeeProxyContract.applyActionToExtension( + requestAfterCreation.extensions, + DataERC20FeeCreate.actionCreationFull, + requestAfterCreation, + TestData.payeeRaw.identity, + TestData.arbitraryTimestamp, + ), + ).toEqual(DataERC20FeeCreate.extensionFullState); + }); + + it('cannot applyActionToExtensions of creation signed by someone else than the payee', () => { + expect(() => { + erc20FeeProxyContract.applyActionToExtension( + requestAfterCreation.extensions, + DataERC20FeeCreate.actionCreationFull, + requestAfterCreation, + TestData.otherIdRaw.identity, + TestData.arbitraryTimestamp, + ); + }).toThrowError('The signer must be the payee'); + }); + + it('cannot applyActionToExtensions of creation signed by the payer', () => { + expect(() => { + erc20FeeProxyContract.applyActionToExtension( + requestAfterCreation.extensions, + DataERC20FeeCreate.actionCreationFull, + requestAfterCreation, + TestData.payerRaw.identity, + TestData.arbitraryTimestamp, + ); + }).toThrowError('The signer must be the payee'); + }); + + it('cannot applyActionToExtensions of creation without payee', () => { + const requestNoPayee: RequestLogicTypes.IRequest = deepCopy(requestAfterCreation); + delete requestNoPayee.payee; + expect(() => { + erc20FeeProxyContract.applyActionToExtension( + requestNoPayee.extensions, + DataERC20FeeCreate.actionCreationFull, + requestNoPayee, + TestData.otherIdRaw.identity, + TestData.arbitraryTimestamp, + ); + }).toThrowError('The request must have a payee'); + }); + }); + it('cannot applyActionToExtensions of creation with a previous state', () => { // 'must throw' expect(() => { diff --git a/packages/advanced-logic/test/extensions/payment-network/meta.test.ts b/packages/advanced-logic/test/extensions/payment-network/meta.test.ts index 0b318d0fe5..2010d17c11 100644 --- a/packages/advanced-logic/test/extensions/payment-network/meta.test.ts +++ b/packages/advanced-logic/test/extensions/payment-network/meta.test.ts @@ -130,6 +130,27 @@ describe('extensions/payment-network/meta', () => { ).toEqual(MetaCreate.extensionFullStateMultipleAnyToErc20); }); + it('cannot applyActionToExtensions of creation after the request creation if not signed by the payee', () => { + const requestAfterCreation: RequestLogicTypes.IRequest = deepCopy( + MetaCreate.requestStateNoExtensions, + ); + requestAfterCreation.events.push({ + actionSigner: TestData.otherIdRaw.identity, + name: RequestLogicTypes.ACTION_NAME.ADD_EXTENSIONS_DATA, + parameters: { extensionsDataLength: 1 }, + timestamp: TestData.arbitraryTimestamp, + }); + expect(() => { + metaPn.applyActionToExtension( + requestAfterCreation.extensions, + MetaCreate.actionCreationMultipleAnyToErc20, + requestAfterCreation, + TestData.otherIdRaw.identity, + TestData.arbitraryTimestamp, + ); + }).toThrowError('The signer must be the payee'); + }); + it('cannot applyActionToExtensions of creation with a previous state', () => { expect(() => { metaPn.applyActionToExtension( @@ -359,6 +380,25 @@ describe('extensions/payment-network/meta', () => { }).toThrowError(`No payment network with identifier wrongId`); }); + it('cannot applyActionToExtensions applyApplyActionToExtension to create a sub-pn', () => { + expect(() => { + metaPn.applyActionToExtension( + MetaCreate.requestFullStateCreated.extensions, + { + ...MetaCreate.actionApplyActionToPn, + parameters: { + action: ExtensionTypes.PnMeta.ACTION.CREATE, + pnIdentifier: MetaCreate.salt1, + parameters: MetaCreate.baseParams(MetaCreate.salt1), + }, + }, + MetaCreate.requestFullStateCreated, + TestData.payeeRaw.identity, + TestData.arbitraryTimestamp, + ); + }).toThrowError('Invalid action'); + }); + it('cannot applyActionToExtensions applyApplyActionToExtension when the action does not exists on the sub pn', () => { const actionWithInvalidPnAction = deepCopy(MetaCreate.actionApplyActionToPn); actionWithInvalidPnAction.parameters.action = 'wrongAction' as ExtensionTypes.ACTION; diff --git a/packages/request-logic/test/index.test.ts b/packages/request-logic/test/index.test.ts index 0ee3226fd1..fad44c7223 100644 --- a/packages/request-logic/test/index.test.ts +++ b/packages/request-logic/test/index.test.ts @@ -1,5 +1,7 @@ import { EventEmitter } from 'events'; +import { AdvancedLogic } from '@requestnetwork/advanced-logic'; +import { CurrencyManager } from '@requestnetwork/currency'; import * as MultiFormat from '@requestnetwork/multi-format'; import { AdvancedLogicTypes, RequestLogicTypes, TransactionTypes } from '@requestnetwork/types'; @@ -1015,6 +1017,83 @@ describe('index', () => { }); describe('getRequestFromId', () => { + it('ignores an ACCEPT with an unauthorized new extension and allows a later valid ACCEPT', async () => { + const advancedLogic = new AdvancedLogic(CurrencyManager.getDefault()); + const rejectedAccept = sign( + { + name: RequestLogicTypes.ACTION_NAME.ACCEPT, + parameters: { + requestId, + extensionsData: [ + advancedLogic.extensions.contentData.createCreationAction({ + content: { invoiceNumber: 'test-invoice' }, + }), + ], + }, + version: CURRENT_VERSION, + }, + TestData.payerRaw.signatureParams, + ); + const transactions: TransactionTypes.IReturnGetTransactions['result']['transactions'] = [ + { + state: TransactionTypes.TransactionState.CONFIRMED, + timestamp: 1, + transaction: { data: JSON.stringify(action) }, + }, + { + state: TransactionTypes.TransactionState.CONFIRMED, + timestamp: 2, + transaction: { data: JSON.stringify(rejectedAccept) }, + }, + ]; + const transactionManager: TransactionTypes.ITransactionManager = { + ...fakeTransactionManager, + getTransactionsByChannelId: async () => ({ + meta: { ignoredTransactions: [] }, + result: { transactions }, + }), + }; + const requestLogic = new RequestLogic(transactionManager, undefined, advancedLogic); + + const rejected = await requestLogic.getRequestFromId(requestId); + + expect(rejected.result.request?.state).toBe(RequestLogicTypes.STATE.CREATED); + expect(rejected.result.request?.extensions).toEqual({}); + expect(rejected.result.request?.events.map((event) => event.name)).toEqual([ + RequestLogicTypes.ACTION_NAME.CREATE, + ]); + expect(rejected.meta.ignoredTransactions).toEqual([ + expect.objectContaining({ + reason: 'The signer must be the payee', + transaction: expect.objectContaining({ action: rejectedAccept }), + }), + ]); + + const validAccept = sign( + { + name: RequestLogicTypes.ACTION_NAME.ACCEPT, + parameters: { requestId }, + version: CURRENT_VERSION, + }, + TestData.payerRaw.signatureParams, + ); + transactions.push({ + state: TransactionTypes.TransactionState.CONFIRMED, + timestamp: 3, + transaction: { data: JSON.stringify(validAccept) }, + }); + + const accepted = await requestLogic.getRequestFromId(requestId); + + expect(accepted.result.request?.state).toBe(RequestLogicTypes.STATE.ACCEPTED); + expect(accepted.result.request?.extensions).toEqual({}); + expect(accepted.result.request?.events.map((event) => event.name)).toEqual([ + RequestLogicTypes.ACTION_NAME.CREATE, + RequestLogicTypes.ACTION_NAME.ACCEPT, + ]); + expect(accepted.meta.ignoredTransactions).toEqual(rejected.meta.ignoredTransactions); + }); + it('can getRequestFromId', async () => { const actionCreate: RequestLogicTypes.IAction = sign( { From c18e99cabe13ddb66dc925fd926e97a35c6979c6 Mon Sep 17 00:00:00 2001 From: MantisClone Date: Sat, 31 Oct 2026 00:13:09 +0100 Subject: [PATCH 2/2] chore: declare request logic test dependency --- packages/request-logic/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/request-logic/package.json b/packages/request-logic/package.json index e0356dd967..96c21fdb7e 100644 --- a/packages/request-logic/package.json +++ b/packages/request-logic/package.json @@ -48,6 +48,7 @@ "tslib": "2.8.1" }, "devDependencies": { + "@requestnetwork/currency": "0.34.0", "@types/jest": "29.5.6", "@types/semver": "7.3.4", "jest": "29.5.0",