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 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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.

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 @@ -80,14 +114,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 @@ -97,9 +129,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: getUnifiedAddress(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,
});
});
});
});
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 '@frequency-chain/ethereum-utils';
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?: number | any;
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
);
}
}
Loading
Loading