Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 🎸 allow submitting offline tx with just payload #1438

Merged
merged 4 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions src/api/client/Network.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<SubmissionDetails> {
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}`;
Expand All @@ -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;

Expand Down
76 changes: 70 additions & 6 deletions src/api/client/__tests__/Network.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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', {
Expand All @@ -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,
Expand Down
29 changes: 23 additions & 6 deletions src/base/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;

Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/testUtils/mocks/dataSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -4025,13 +4024,15 @@ export const createMockExtrinsics = (
{
toHex: () => createMockStringCodec(),
hash: createMockHash(),
addSignature: (): void => {},
},
];
return createMockCodec(
[
{
toHex,
hash,
addSignature: (): void => {},
},
],
!extrinsics
Expand Down
17 changes: 17 additions & 0 deletions src/utils/typeguards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import {
SellLockupClaim,
SingleClaimCondition,
TickerOwnerRole,
TransactionPayload,
TransactionPayloadInput,
UnscopedClaim,
VenueOwnerRole,
} from '~/types';
Expand Down Expand Up @@ -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;
};
Loading