Skip to content

feat: implement pallet_msa::withdraw_tokens extrinsic #2402

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

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
49116ab
feat: implement pallet_msa::withdraw_tokens extrinsic
JoeCap08055 May 12, 2025
563baed
fix: doc lint
JoeCap08055 May 13, 2025
a7ac8db
fix: broken unit tests
JoeCap08055 May 13, 2025
551c6c5
feat: bump spec version
JoeCap08055 May 13, 2025
6a19e64
fix: update MSA pallet README
JoeCap08055 May 13, 2025
b57132e
feat: unit tests
JoeCap08055 May 13, 2025
1e78244
feat: add benchmarks for withdraw_tokens extrinsic
JoeCap08055 May 13, 2025
582d13d
Merge branch 'main' into feat/withdraw-tokens-from-msa
JoeCap08055 May 13, 2025
0363457
fix: fix payload signature verification after merge EIP-712 from main
JoeCap08055 May 13, 2025
1c7328f
fix: bump spec version
JoeCap08055 May 14, 2025
fa04839
fix: formatting
JoeCap08055 May 14, 2025
1528d19
feat: free extrinsic
JoeCap08055 May 21, 2025
a09efa1
fix: formatting
JoeCap08055 May 21, 2025
c9e614d
Merge branch 'main' into feat/withdraw-tokens-from-msa
JoeCap08055 May 21, 2025
592ea06
fix: lint
JoeCap08055 May 21, 2025
a504353
fix: signature registry checks in extension, e2e nonce issues
JoeCap08055 May 22, 2025
dc5e272
fix: merge from main
JoeCap08055 May 22, 2025
1670a85
Update pallets/msa/src/lib.rs
JoeCap08055 May 22, 2025
340be0f
fix: revert change to enequeue_signature and update function doc
JoeCap08055 May 23, 2025
a865495
chore: debug eth signature replay
JoeCap08055 May 23, 2025
c857b30
fix: formatting
JoeCap08055 May 23, 2025
0a28176
fix: comment
JoeCap08055 May 23, 2025
217bcc8
fix: docs
JoeCap08055 May 25, 2025
40354f2
fix: docs
JoeCap08055 May 25, 2025
9fb0527
fix: keep signed extension tests with others
JoeCap08055 May 27, 2025
857fad7
feat: disallow retire_msa if MSA holds tokens
JoeCap08055 May 27, 2025
1308dac
Merge remote-tracking branch 'origin' into feat/withdraw-tokens-from-msa
JoeCap08055 May 28, 2025
fb18b4e
fix: e2e test for retire msa with tokens
JoeCap08055 May 28, 2025
0638468
fix: formatting
JoeCap08055 May 28, 2025
3392086
fix: e2e tests
JoeCap08055 May 28, 2025
4d8bb32
fix: withdraw tokens helper
JoeCap08055 May 28, 2025
73ede40
fix: bump spec version
JoeCap08055 May 28, 2025
fbf0324
fix: merge from main
JoeCap08055 May 28, 2025
6ba26e1
fix: lint
JoeCap08055 May 28, 2025
532fa6c
fix: PR comments
JoeCap08055 May 29, 2025
311dbfc
Update weights
JoeCap08055 May 29, 2025
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

235 changes: 228 additions & 7 deletions e2e/msa/msaTokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import '@frequency-chain/api-augment';
import assert from 'assert';
import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers';
import { ethereumAddressToKeyringPair } from '../scaffolding/ethereum';
import { AuthorizedKeyData, ExtrinsicHelper } from '../scaffolding/extrinsicHelpers';
import { ethereumAddressToKeyringPair, getUnifiedAddress, getUnifiedPublicKey } from '../scaffolding/ethereum';
import { getFundingSource } from '../scaffolding/funding';
import { H160 } from '@polkadot/types/interfaces';
import { bnToU8a, hexToU8a, stringToU8a } from '@polkadot/util';
import { KeyringPair } from '@polkadot/keyring/types';
import { keccak256AsU8a } from '@polkadot/util-crypto';
import { getExistentialDeposit } from '../scaffolding/helpers';
import {
CENTS,
createAndFundKeypair,
createKeys,
DOLLARS,
generateAuthorizedKeyPayload,
getExistentialDeposit,
signPayloadSr25519,
Sr25519Signature,
} from '../scaffolding/helpers';
import { u64 } from '@polkadot/types';
import { Codec } from '@polkadot/types/types';

const fundingSource = getFundingSource(import.meta.url);
const TRANSFER_AMOUNT = 1n * DOLLARS;

/**
*
Expand Down Expand Up @@ -80,14 +92,13 @@ describe('MSAs Holding Tokens', function () {

describe('Send tokens to MSA', function () {
it('should send tokens to the MSA', async function () {
const ed = await getExistentialDeposit();
const transferAmount = 1n + ed;
const ed = getExistentialDeposit();
let accountData = await ExtrinsicHelper.getAccountInfo(ethKeys);
const initialBalance = accountData.data.free.toBigInt();
const op = ExtrinsicHelper.transferFunds(
fundingSource,
ethereumAddressToKeyringPair(ethAddress20),
transferAmount
TRANSFER_AMOUNT
);

const { target: transferEvent } = await op.fundAndSend(fundingSource);
Expand All @@ -97,9 +108,219 @@ describe('MSAs Holding Tokens', function () {
const finalBalance = accountData.data.free.toBigInt();
assert.equal(
finalBalance,
initialBalance + transferAmount,
initialBalance + TRANSFER_AMOUNT,
'Final balance should be increased by transfer amount'
);
});
});

describe('withdrawTokens', function () {
let msaKeys: KeyringPair;
let msaId: u64;
let msaAddress: H160;
let otherMsaKeys: KeyringPair;
let secondaryKey: KeyringPair;
let defaultPayload: AuthorizedKeyData;
let payload: AuthorizedKeyData;
let ownerSig: Sr25519Signature;
let badSig: Sr25519Signature;
let authorizedKeyData: Codec;

before(async function () {
// Setup an MSA with tokens
msaKeys = await createAndFundKeypair(fundingSource, 5n * CENTS);
let { target } = await ExtrinsicHelper.createMsa(msaKeys).signAndSend();
assert.notEqual(target?.data.msaId, undefined, 'MSA Id not in expected event');
msaId = target!.data.msaId;

// Setup another MSA control key
otherMsaKeys = await createAndFundKeypair(fundingSource, 5n * CENTS);
({ target } = await ExtrinsicHelper.createMsa(otherMsaKeys).signAndSend());
assert.notEqual(target?.data.msaId, undefined, 'MSA Id not in expected event');

const { accountId } = await ExtrinsicHelper.apiPromise.call.msaRuntimeApi.getEthereumAddressForMsaId(msaId);
msaAddress = accountId;

// Create unfunded keys; this extrinsic should be free
secondaryKey = createKeys();

// Default payload making it easier to test `withdrawTokens`
defaultPayload = {
msaId,
authorizedPublicKey: getUnifiedPublicKey(secondaryKey),
};
});

beforeEach(async function () {
payload = await generateAuthorizedKeyPayload(defaultPayload);
});

it('should fail if origin is not address contained in the payload (NotKeyOwner)', async function () {
const badPayload = { ...payload, authorizedPublicKey: getUnifiedAddress(createKeys()) }; // Invalid MSA ID
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', badPayload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, ownerSig, badPayload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 5', // NotKeyOwner,
});
});

it('should fail if MSA owner signature is invalid (MsaOwnershipInvalidSignature)', async function () {
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', payload);
badSig = signPayloadSr25519(createKeys(), authorizedKeyData); // Invalid signature
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, badSig, payload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 8', // MsaOwnershipInvalidSignature
});
});

it('should fail if expiration has passed (MsaOwnershipInvalidSignature)', async function () {
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
expiration: (await ExtrinsicHelper.getLastBlock()).block.header.number.toNumber(),
});
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', newPayload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, ownerSig, newPayload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 8', // MsaOwnershipInvalidSignature,
});
});

it('should fail if expiration is not yet valid (MsaOwnershipInvalidSignature)', async function () {
const maxMortality = ExtrinsicHelper.api.consts.msa.mortalityWindowSize.toNumber();
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
expiration: (await ExtrinsicHelper.getLastBlock()).block.header.number.toNumber() + maxMortality + 999,
});
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', newPayload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, ownerSig, newPayload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 8', // MsaOwnershipInvalidSignature,
});
});

it('should fail if origin is an MSA control key (IneligibleOrigin)', async function () {
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', payload);
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
authorizedPublicKey: getUnifiedPublicKey(otherMsaKeys),
});
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', newPayload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(otherMsaKeys, msaKeys, ownerSig, newPayload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 10', // IneligibleOrigin,
});
});

it('should fail if payload signer does not control the MSA in the signed payload (InvalidMsaKey)', async function () {
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
msaId: new u64(ExtrinsicHelper.api.registry, 9999),
});
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', newPayload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, ownerSig, newPayload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 1', // InvalidMsaKey,
});
});

it('should fail if payload signer is not an MSA control key (InvalidMsaKey)', async function () {
const badSigner = createKeys();
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
msaId: new u64(ExtrinsicHelper.api.registry, 9999),
});
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', newPayload);
ownerSig = signPayloadSr25519(badSigner, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, badSigner, ownerSig, newPayload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 1', // InvalidMsaKey,
});
});

it('should fail if MSA does not have a balance', async function () {
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', payload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, ownerSig, payload);
await assert.rejects(op.signAndSend('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 9', // InsufficientBalanceToWithdraw,
});
});

it('should succeed', async function () {
const {
data: { free: startingBalance },
} = await ExtrinsicHelper.getAccountInfo(secondaryKey);
// Send tokens to MSA
const op1 = ExtrinsicHelper.transferFunds(
fundingSource,
ethereumAddressToKeyringPair(msaAddress),
TRANSFER_AMOUNT
);
await assert.doesNotReject(op1.signAndSend(), 'MSA funding failed');
authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', payload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
const op2 = ExtrinsicHelper.withdrawTokens(secondaryKey, msaKeys, ownerSig, payload);
await assert.doesNotReject(op2.signAndSend('current'), 'token transfer transaction should have succeeded');
// Destination account should have had balance increased
const {
data: { free: endingBalance },
} = await ExtrinsicHelper.getAccountInfo(secondaryKey);

assert(
startingBalance.toBigInt() + TRANSFER_AMOUNT === endingBalance.toBigInt(),
'balance of recieve should have increased by the transfer amount minus fee'
);
});

it('should fail for duplicate signature submission (MsaOwnershipInvalidSignature)', async function () {
// Due to the fact that we're testing this free extrinsic with unfunded accounts (ie, with no nonce),
// we'll use a unique keypair for this test so that it doesn't matter whether other tests using
// the same keypair ended up funding the account & therefore initializing a nonce.
const keys = createKeys();
payload.authorizedPublicKey = getUnifiedPublicKey(keys);

const op1 = ExtrinsicHelper.transferFunds(
fundingSource,
ethereumAddressToKeyringPair(msaAddress),
TRANSFER_AMOUNT
);
await assert.doesNotReject(op1.signAndSend(), 'MSA funding failed');

authorizedKeyData = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', payload);
ownerSig = signPayloadSr25519(msaKeys, authorizedKeyData);
let op2 = ExtrinsicHelper.withdrawTokens(keys, msaKeys, ownerSig, payload);
await assert.doesNotReject(op2.signAndSend('current'), 'token withdrawal should have succeeded');

// Re-fund MSA so we don't fail for that
await assert.doesNotReject(op1.signAndSend(), 'MSA re-funding failed');

op2 = ExtrinsicHelper.withdrawTokens(keys, msaKeys, ownerSig, payload);
await assert.rejects(op2.signAndSend(0), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 8', // MsaOwnershipInvalidSignature,
});
});
});
});
37 changes: 29 additions & 8 deletions e2e/scaffolding/extrinsicHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ import { ApiPromise, ApiRx } from '@polkadot/api';
import { ApiTypes, AugmentedEvent, SubmittableExtrinsic, SignerOptions } from '@polkadot/api/types';
import { KeyringPair } from '@polkadot/keyring/types';
import { Compact, u128, u16, u32, u64, Vec, Option, Bool } from '@polkadot/types';
import {
FrameSystemAccountInfo,
PalletTimeReleaseReleaseSchedule,
SpRuntimeDispatchError,
PalletSchedulerScheduled,
} from '@polkadot/types/lookup';
import { FrameSystemAccountInfo, SpRuntimeDispatchError } from '@polkadot/types/lookup';
import { AnyJson, AnyNumber, AnyTuple, Codec, IEvent, ISubmittableResult } from '@polkadot/types/types';
import { firstValueFrom, filter, map, pipe, tap } from 'rxjs';
import { getBlockNumber, getExistentialDeposit, getFinalizedBlockNumber, log, MultiSignatureType } from './helpers';
Expand All @@ -31,6 +26,7 @@ import { u8aWrapBytes } from '@polkadot/util';
import type { AccountId32, Call, H256 } from '@polkadot/types/interfaces/runtime';
import { hasRelayChain } from './env';
import { getUnifiedAddress, getUnifiedPublicKey } from './ethereum';
import { RpcErrorInterface } from '@polkadot/rpc-provider/types';

export interface ReleaseSchedule {
start: number;
Expand All @@ -44,6 +40,11 @@ export interface AddKeyData {
expiration?: any;
newPublicKey?: any;
}
export interface AuthorizedKeyData {
msaId: u64;
expiration?: AnyNumber;
authorizedPublicKey: KeyringPair['publicKey'];
}
export interface AddProviderPayload {
authorizedMsaId?: u64;
schemaIds?: u16[];
Expand Down Expand Up @@ -91,6 +92,10 @@ export interface PaginatedDeleteSignaturePayloadV2 {
expiration?: any;
}

export function isRpcError<T = string>(e: any): e is RpcErrorInterface<T> {
return e?.name === 'RpcError';
}

export class EventError extends Error {
name: string = '';
message: string = '';
Expand Down Expand Up @@ -188,6 +193,7 @@ export class Extrinsic<N = unknown, T extends ISubmittableResult = ISubmittableR
try {
const op = this.extrinsic();
// Era is 0 for tests due to issues with BirthBlock

return await firstValueFrom(
op.signAndSend(this.keys, { nonce, era: 0, ...options }).pipe(
tap((result) => {
Expand All @@ -208,8 +214,11 @@ export class Extrinsic<N = unknown, T extends ISubmittableResult = ISubmittableR
)
);
} catch (e) {
if ((e as any).name === 'RpcError' && inputNonce === 'auto') {
console.error("WARNING: Unexpected RPC Error! If it is expected, use 'current' for the nonce.");
if (isRpcError(e)) {
if (inputNonce === 'auto') {
console.error("WARNING: Unexpected RPC Error! If it is expected, use 'current' for the nonce.");
}
log(`RpcError:`, { code: e.code, data: e.data });
}
throw e;
}
Expand Down Expand Up @@ -928,4 +937,16 @@ export class ExtrinsicHelper {
ExtrinsicHelper.api.events.passkey.TransactionExecutionSuccess
);
}

public static withdrawTokens(
keys: KeyringPair,
ownerKeys: KeyringPair,
ownerSignature: MultiSignatureType,
payload: AddKeyData
) {
return new Extrinsic(
() => ExtrinsicHelper.api.tx.msa.withdrawTokens(getUnifiedPublicKey(ownerKeys), ownerSignature, payload),
keys
);
}
}
15 changes: 14 additions & 1 deletion e2e/scaffolding/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
AddKeyData,
AddProviderPayload,
AuthorizedKeyData,
EventMap,
ExtrinsicHelper,
ItemizedSignaturePayloadV2,
Expand Down Expand Up @@ -164,7 +165,7 @@ export async function getBlockNumber(): Promise<number> {

let cacheED: null | bigint = null;

export async function getExistentialDeposit(): Promise<bigint> {
export function getExistentialDeposit(): bigint {
if (cacheED !== null) return cacheED;
return (cacheED = ExtrinsicHelper.api.consts.balances.existentialDeposit.toBigInt());
}
Expand All @@ -182,6 +183,18 @@ export async function generateAddKeyPayload(
};
}

export async function generateAuthorizedKeyPayload(
payloadInputs: AuthorizedKeyData,
expirationOffset: number = 100,
blockNumber?: number
): Promise<AuthorizedKeyData> {
const { expiration, ...payload } = payloadInputs;
return {
expiration: expiration || (blockNumber || (await getBlockNumber())) + expirationOffset,
...payload,
};
}

export async function generateItemizedSignaturePayload(
payloadInputs: ItemizedSignaturePayloadV2,
expirationOffset: number = 100,
Expand Down
Loading