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

Merged
merged 44 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
aa07490
Merge branch 'main' into feat/withdraw-tokens-from-msa
JoeCap08055 Jun 1, 2025
0d34cd8
fix: cargo fmt
JoeCap08055 Jun 3, 2025
a7d52af
Merge branch 'main' into feat/withdraw-tokens-from-msa
JoeCap08055 Jun 4, 2025
22cca43
fix: PR comments
JoeCap08055 Jun 4, 2025
2d09e92
fix: missing js imports in e2e
JoeCap08055 Jun 6, 2025
7bcabfd
fix: use nightly Rust toolchain for cargo deny until we move to Rust …
JoeCap08055 Jun 6, 2025
cb3ec89
fix: lock base64ct package version to fix cargo deny in CI
JoeCap08055 Jun 11, 2025
ceb9636
fix: update lock file
JoeCap08055 Jun 11, 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,117 changes: 559 additions & 558 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,7 @@ inherits = "release"
strip = false
codegen-units = 16
lto = false

[patch.crates-io]
base64ct = { git = "https://github.com/RustCrypto/formats.git", tag = "base64ct/v1.7.2", package = "base64ct" }

47 changes: 42 additions & 5 deletions e2e/msa/msaKeyManagementDelete.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import '@frequency-chain/api-augment';
import assert from 'assert';
import {
createAndFundKeypairs,
createKeys,
signPayloadSr25519,
generateAddKeyPayload,
createProviderKeysAndId,
DOLLARS,
createAndFundKeypairs,
generateAddKeyPayload,
generateAuthorizedKeyPayload,
signPayloadSr25519,
} from '../scaffolding/helpers';
import { KeyringPair } from '@polkadot/keyring/types';
import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers';
import { u64 } from '@polkadot/types';
import { getFundingSource } from '../scaffolding/funding';
import { getUnifiedPublicKey } from '@frequency-chain/ethereum-utils';
import { ethereumAddressToKeyringPair, getUnifiedPublicKey } from '@frequency-chain/ethereum-utils';
import { H160 } from '@polkadot/types/interfaces';

const fundingSource = getFundingSource(import.meta.url);

describe('MSA Key management: delete keys and retire', function () {
let keys: KeyringPair;
let secondaryKey: KeyringPair;
let msaId: u64;
let msaAccountId: H160;

before(async function () {
// Generates a msa with two control keys
Expand All @@ -30,6 +33,17 @@ describe('MSA Key management: delete keys and retire', function () {
assert.notEqual(target?.data.msaId, undefined, 'MSA Id not in expected event');
msaId = target!.data.msaId;

// Send tokens to the MSA account
({ accountId: msaAccountId } =
await ExtrinsicHelper.apiPromise.call.msaRuntimeApi.getEthereumAddressForMsaId(msaId));
const fundingOp = ExtrinsicHelper.transferFunds(
fundingSource,
ethereumAddressToKeyringPair(msaAccountId),
1n * DOLLARS
);
const { target: fundingEvent } = await fundingOp.signAndSend();
assert.notEqual(fundingEvent, undefined, 'should have funded MSA account');

const payload = await generateAddKeyPayload({
msaId,
newPublicKey: getUnifiedPublicKey(secondaryKey),
Expand Down Expand Up @@ -85,7 +99,30 @@ describe('MSA Key management: delete keys and retire', function () {
assert.notEqual(event, undefined, 'should have returned PublicKeyDeleted event');
});

it('should allow retiring MSA after additional keys have been deleted', async function () {
it('should fail to retire MSA if MSA holds tokens', async function () {
// Make sure we are finalized removing before trying to retire
await ExtrinsicHelper.waitForFinalization();

const retireMsaOp = ExtrinsicHelper.retireMsa(keys);
await assert.rejects(retireMsaOp.signAndSend('current'), {
name: 'RpcError',
message: /Custom error: 11/,
});
});

it('should allow retiring MSA after additional keys have been deleted and tokens withdran', async function () {
// Withdraw tokens from MSA account
const receiverKeys = createKeys('receiver keys');
const payload = await generateAuthorizedKeyPayload({
msaId,
authorizedPublicKey: getUnifiedPublicKey(receiverKeys),
});
const payloadToSign = ExtrinsicHelper.api.registry.createType('PalletMsaAuthorizedKeyData', payload);
const ownerSig = signPayloadSr25519(keys, payloadToSign);
const drainMsaOp = ExtrinsicHelper.withdrawTokens(receiverKeys, keys, ownerSig, payload);
const { target: withdrawTransferEvent } = await drainMsaOp.signAndSend();
assert.notEqual(withdrawTransferEvent, undefined, 'should have withdrawn tokens from MSA account');

const retireMsaOp = ExtrinsicHelper.retireMsa(keys);

// Make sure we are finalized removing before trying to retire
Expand Down
246 changes: 238 additions & 8 deletions e2e/msa/msaTokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import '@frequency-chain/api-augment';
import assert from 'assert';
import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers';
import { ethereumAddressToKeyringPair } from '@frequency-chain/ethereum-utils';
import { AuthorizedKeyData, ExtrinsicHelper } from '../scaffolding/extrinsicHelpers';
import {
EcdsaSignature,
createAuthorizedKeyData,
ethereumAddressToKeyringPair,
getUnifiedAddress,
getUnifiedPublicKey,
signEip712,
} from '@frequency-chain/ethereum-utils';
import { getFundingSource } from '../scaffolding/funding';
import { H160 } from '@polkadot/types/interfaces';
import { bnToU8a, hexToU8a, stringToU8a } from '@polkadot/util';
import { bnToU8a, hexToU8a, stringToU8a, u8aToHex } 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,
getEthereumKeyPairFromUnifiedAddress,
} 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 @@ -37,6 +54,23 @@ function generateMsaAddress(msaId: string | number | bigint): H160 {
return ExtrinsicHelper.api.registry.createType('H160', hash.slice(-20));
}

async function generateSignedAuthorizedKeyPayload(keys: KeyringPair, payload: AuthorizedKeyData) {
const signingPayload = createAuthorizedKeyData(
payload.msaId!.toString(),
u8aToHex(payload.authorizedPublicKey),
payload.expiration
);
const ownerSig = await signEip712(
u8aToHex(getEthereumKeyPairFromUnifiedAddress(getUnifiedAddress(keys)).secretKey),
signingPayload
);

return {
signingPayload,
ownerSig,
};
}

describe('MSAs Holding Tokens', function () {
const MSA_ID_1234 = 1234; // MSA ID for testing
const CHECKSUMMED_ETH_ADDR_1234 = '0x65928b9a88Db189Eea76F72d86128Af834d64c32'; // Checksummed Ethereum address for MSA ID 1234
Expand Down Expand Up @@ -82,14 +116,12 @@ 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;
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 @@ -99,9 +131,207 @@ 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: EcdsaSignature;
let badSig: EcdsaSignature;

before(async function () {
// Setup an MSA with tokens
msaKeys = await createAndFundKeypair(fundingSource, 5n * CENTS, undefined, undefined, 'ethereum');
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, undefined, undefined, 'ethereum');
({ 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(undefined, 'ethereum');

// 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: getUnifiedPublicKey(createKeys()) }; // Invalid MSA ID
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, payload));
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 () {
({ ownerSig: badSig } = await generateSignedAuthorizedKeyPayload(createKeys('badKeys', 'ethereum'), payload));
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(),
});
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, newPayload));
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,
});
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, newPayload));
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 () {
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
authorizedPublicKey: getUnifiedPublicKey(otherMsaKeys),
});
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, newPayload));
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),
});
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, newPayload));
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(undefined, 'ethereum');
const newPayload = await generateAuthorizedKeyPayload({
...defaultPayload,
msaId: new u64(ExtrinsicHelper.api.registry, 9999),
});
({ ownerSig } = await generateSignedAuthorizedKeyPayload(badSigner, newPayload));
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 () {
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, payload));
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');
({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, payload));
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 () {
// In order to test this, we need to create a new keypair and fund it, because otherwise the nonce will
// be the same for both transactions (and, because we're using Edcs signatures, the signature will be the same).
// Not sure exactly what happens in this case, but it seems to be that the second transaction is siliently dropped
// by the node, but the status call back in polkadot.js still resolves (ie, gets 'isInBlock' or 'isFinalized')
const keys = await createAndFundKeypair(fundingSource, 5n * CENTS, undefined, undefined, 'ethereum');
payload.authorizedPublicKey = getUnifiedPublicKey(keys);

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

({ ownerSig } = await generateSignedAuthorizedKeyPayload(msaKeys, payload));
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('current'), {
name: 'RpcError',
code: 1010,
data: 'Custom error: 8', // MsaOwnershipInvalidSignature,
});
});
});
});
Loading