diff --git a/src/api/client/Network.ts b/src/api/client/Network.ts index 0c7227d88e..0b6a3f5ad7 100644 --- a/src/api/client/Network.ts +++ b/src/api/client/Network.ts @@ -1,4 +1,4 @@ -import { isHex } from '@polkadot/util'; +import { compactToU8a, isHex, u8aConcat } from '@polkadot/util'; import BigNumber from 'bignumber.js'; import { handleExtrinsicFailure, pollForTransactionFinalization } from '~/base/utils'; @@ -16,12 +16,13 @@ import { ProtocolFees, SubCallback, SubmissionDetails, - TransactionPayload, + TransactionPayloadInput, TransferPolyxParams, TxTag, UnsubCallback, } from '~/types'; import { Ensured } from '~/types/utils'; +import { isFullOfflinePayload, isRawPayload } from '~/utils'; import { TREASURY_MODULE_ADDRESS } from '~/utils/constants'; import { balanceToBigNumber, @@ -200,12 +201,36 @@ export class Network { * @throws if the signature is not hex encoded */ public async submitTransaction( - txPayload: TransactionPayload, + txPayload: TransactionPayloadInput, signature: string ): Promise { const { context } = this; - const { method, payload } = txPayload; - const transaction = context.polymeshApi.tx(method); + + let payload; + let address: string; + let extrinsic; + + if (isFullOfflinePayload(txPayload)) { + payload = txPayload.payload; + address = payload.address; + extrinsic = context.createType('Extrinsic', payload); + } else { + address = txPayload.address; + if (isRawPayload(txPayload)) { + let data: string; + ({ address, data } = txPayload); + + const call = context.createType('Call', data); + + extrinsic = context.createType('Extrinsic', call); + + // The payload must be prefixed with the SCALE encoded length of the payload + payload = u8aConcat(compactToU8a(call.encodedLength), data); + } else { + payload = txPayload; + extrinsic = context.createType('Extrinsic', payload); + } + } if (!signature.startsWith('0x')) { signature = `0x${signature}`; @@ -218,11 +243,13 @@ export class Network { data: { signature }, }); - transaction.addSignature(payload.address, signature, payload); + extrinsic.addSignature(address, signature, payload); + + const transaction = context.polymeshApi.tx(extrinsic); const submissionDetails: SubmissionDetails = { blockHash: '', - transactionHash: transaction.hash.toString(), + transactionHash: extrinsic.hash.toString(), transactionIndex: new BigNumber(-1), } as SubmissionDetails; diff --git a/src/api/client/__tests__/Network.ts b/src/api/client/__tests__/Network.ts index f7369d784f..41250c4f3a 100644 --- a/src/api/client/__tests__/Network.ts +++ b/src/api/client/__tests__/Network.ts @@ -1,4 +1,5 @@ import { SubmittableResult } from '@polkadot/api'; +import { GenericExtrinsic } from '@polkadot/types/extrinsic'; import BigNumber from 'bignumber.js'; import { when } from 'jest-when'; @@ -9,7 +10,7 @@ import { eventsByArgs } from '~/middleware/queries/events'; import { extrinsicByHash } from '~/middleware/queries/extrinsics'; import { CallIdEnum, EventIdEnum, ModuleIdEnum } from '~/middleware/types'; import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks'; -import { MockTxStatus } from '~/testUtils/mocks/dataSources'; +import { createMockCall, MockTxStatus } from '~/testUtils/mocks/dataSources'; import { Mocked } from '~/testUtils/types'; import { AccountBalance, ErrorCode, MiddlewareMetadata, TransactionPayload, TxTags } from '~/types'; import * as utilsConversionModule from '~/utils/conversion'; @@ -538,16 +539,27 @@ describe('Network Class', () => { }); describe('method: submitTransaction', () => { - beforeEach(() => { - dsMockUtils.configureMocks(); - }); - const mockPayload = { payload: {}, - rawPayload: {}, + rawPayload: { + data: '0x00randomdata', + address: 'some_address', + }, method: '0x01', metadata: {}, } as unknown as TransactionPayload; + let extrinsic: GenericExtrinsic; + + beforeEach(() => { + [extrinsic] = dsMockUtils.createMockExtrinsics([ + { toHex: (): string => '0x', hash: dsMockUtils.createMockHash('0x01') }, + ]); + + dsMockUtils.configureMocks(); + when(context.createType) + .calledWith('Extrinsic', mockPayload.payload) + .mockReturnValue(extrinsic); + }); it('should submit the transaction to the chain', async () => { const transaction = dsMockUtils.createTxMock('staking', 'bond', { @@ -568,6 +580,58 @@ describe('Network Class', () => { ); }); + it('should support receiving only the inner payload', async () => { + const transaction = dsMockUtils.createTxMock('staking', 'bond', { + autoResolve: MockTxStatus.Succeeded, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.polymeshApi as any).tx = jest.fn().mockReturnValue(transaction); + + const signature = '0x01'; + const result = await network.submitTransaction(mockPayload.payload, signature); + + expect(result).toEqual( + expect.objectContaining({ + transactionHash: '0x01', + result: expect.any(Object), + }) + ); + }); + + it('should support receiving only the inner raw payload', async () => { + const transaction = dsMockUtils.createTxMock('staking', 'bond', { + autoResolve: MockTxStatus.Succeeded, + }); + + const createTypeMock = context.createType; + + const mockCall = createMockCall({ + args: [{ autoResolve: MockTxStatus.Succeeded }], + method: 'staking', + section: 'bond', + }); + + when(createTypeMock) + .calledWith('Call', mockPayload.rawPayload.data) + .mockReturnValue(mockCall); + + when(createTypeMock).calledWith('Extrinsic', mockCall).mockReturnValue(extrinsic); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.polymeshApi as any).tx = jest.fn().mockReturnValue(transaction); + + const signature = '0x01'; + const result = await network.submitTransaction(mockPayload.rawPayload, signature); + + expect(result).toEqual( + expect.objectContaining({ + transactionHash: '0x01', + result: expect.any(Object), + }) + ); + }); + it('should handle non prefixed hex strings', async () => { const transaction = dsMockUtils.createTxMock('asset', 'registerUniqueTicker', { autoResolve: MockTxStatus.Succeeded, diff --git a/src/base/types.ts b/src/base/types.ts index 71cda01f02..f7e7bd2cd8 100644 --- a/src/base/types.ts +++ b/src/base/types.ts @@ -171,30 +171,34 @@ export interface PayingAccountFees { }; } +/** + * Unsigned transaction data in JSON a format + */ export interface TransactionPayload { /** * This is what a Polkadot signer ".signPayload" method expects + * + * @note this field is recommended to be passed in with the signature when submitting a signed transaction */ readonly payload: SignerPayloadJSON; /** * An alternative representation of the payload for which Polkadot signers providing ".signRaw" expect. * - * @note the signature should be prefixed with a single byte to indicate its type. Prepend a zero byte (`0x00`) for ed25519 or a `0x01` byte to indicate sr25519 if the signer implementation does not already do so. + * @note using the field `payload` is generally recommended. The raw version is included so any polkadot compliant signer can sign. + * @note `signRaw` typically returns just the signature. However signatures must be prefixed with a byte to indicate the type. For ed25519 signatures prepend a zero byte (`0x00`), for sr25519 `0x01` byte to indicate sr25519 if the signer implementation does not already do so. */ readonly rawPayload: SignerPayloadRaw; /** * A hex representation of the core extrinsic information. i.e. the extrinsic and args, but does not contain information about who is to sign the transaction. - * - * When submitting the transaction this will be used to construct the extrinsic, to which - * the signer payload and signature will be attached to. - * */ readonly method: HexString; /** - * Additional information attached to the payload, such as IDs or memos about the transaction + * Additional information attached to the payload, such as IDs or memos about the transaction. + * + * @note this is not chain data. Its for convenience for attaching a trace ID */ readonly metadata: Record; @@ -206,6 +210,19 @@ export interface TransactionPayload { readonly multiSig: string | null; } +/** + * The data needed for submitting an offline transaction. + * + * @note One of the following can be used to submit an offline transaction - + * 1. Full payload + * 2. Inner payload field + * 3. Inner raw payload field + */ +export type TransactionPayloadInput = + | TransactionPayload + | TransactionPayload['payload'] + | TransactionPayload['rawPayload']; + export type PolymeshTransaction< ReturnValue = unknown, TransformedReturnValue = ReturnValue, diff --git a/src/testUtils/mocks/dataSources.ts b/src/testUtils/mocks/dataSources.ts index 3fe9839c81..e8f33fde68 100644 --- a/src/testUtils/mocks/dataSources.ts +++ b/src/testUtils/mocks/dataSources.ts @@ -1111,7 +1111,6 @@ function initApi(): void { } as unknown as Registry; mockInstanceContainer.apiInstance.createType = jest.fn(); mockInstanceContainer.apiInstance.runtimeVersion = {} as RuntimeVersion; - // mockInstanceContainer.apiInstance.errors = {} as DecoratedErrors<'promise'>; initTx(); initQuery(); @@ -4025,6 +4024,7 @@ export const createMockExtrinsics = ( { toHex: () => createMockStringCodec(), hash: createMockHash(), + addSignature: (): void => {}, }, ]; return createMockCodec( @@ -4032,6 +4032,7 @@ export const createMockExtrinsics = ( { toHex, hash, + addSignature: (): void => {}, }, ], !extrinsics diff --git a/src/utils/typeguards.ts b/src/utils/typeguards.ts index 0b3a7a02a4..8bd7f79ba3 100644 --- a/src/utils/typeguards.ts +++ b/src/utils/typeguards.ts @@ -57,6 +57,8 @@ import { SellLockupClaim, SingleClaimCondition, TickerOwnerRole, + TransactionPayload, + TransactionPayloadInput, UnscopedClaim, VenueOwnerRole, } from '~/types'; @@ -432,3 +434,18 @@ export const isNftLegBuilder = async ( export const isOffChainLeg = (leg: InstructionLeg): leg is OffChainLeg => { return typeof leg.asset === 'string' && 'offChainAmount' in leg; }; + +/** + * @hidden + */ +export const isFullOfflinePayload = ( + input: TransactionPayload | TransactionPayloadInput +): input is TransactionPayload => { + return 'metadata' in input; // Note: metadata is an arbitrary type discriminate +}; + +export const isRawPayload = ( + input: TransactionPayload['payload'] | TransactionPayload['rawPayload'] +): input is TransactionPayload['rawPayload'] => { + return 'data' in input; +};