Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cdcff35
refactor: [I-04] Duplicated code snippet
stranzhay Feb 11, 2026
b6742cd
fix: [I-03] Mark update_authority a signer in CreateGroupV1 metadata
stranzhay Feb 11, 2026
2745fc6
fix: [I-02] Reject non-child assets/collections/groups removal from p…
stranzhay Feb 11, 2026
54b632c
fix: [I-01] Missing self-reference checks lets groups link to themselves
stranzhay Feb 11, 2026
040d2fb
fix: [L-05] Incorrect account resize on plugin authority approval
stranzhay Feb 12, 2026
ed0694b
fix: [L-04] Unvalidated incoming adapters in external plugin updates
stranzhay Feb 12, 2026
46e0cee
fix: [L-03] Memory violations in WriteGroupExternalPluginAdapterDataV1
stranzhay Feb 12, 2026
596c02d
fix: [L-02] Accounts loaded from raw slice are inconsistent with schema
stranzhay Feb 12, 2026
b9622d0
fix: [L-01] Wrong memmove length when adding parent groups to assets/…
stranzhay Feb 12, 2026
66f3e5d
fix: [M-03] Block Groups plugins when creating assets/collections
stranzhay Feb 12, 2026
2447a45
fix: [M-02] UpdateGroupPluginV1 arbitrarily alters installed plugins
stranzhay Feb 12, 2026
ee73201
fix: [M-01] CloseGroupV1 closes group without checking child assets
stranzhay Feb 12, 2026
175630e
fix: [H-05] External adapter update may corrupt plugin metadata
stranzhay Feb 12, 2026
00ecf65
fix: [H-04] Child asset/collection plugin corruption from group removal
stranzhay Feb 12, 2026
8ff88e2
fix: [H-03] CreateGroupV1 may corrupt parent/child group account
stranzhay Feb 13, 2026
25848e9
fix: [H-02] Multiple IXs incorrectly truncate plugin data in GroupV1 …
stranzhay Feb 13, 2026
65490fa
fix: [H-01] UpdateGroupPluginV1 corrupts group account plugin data
stranzhay Feb 13, 2026
d9c5d0e
fix: fixing tests
stranzhay Feb 13, 2026
4ca498c
fix: fixing tests
stranzhay Feb 13, 2026
bfa8a4f
Merge remote-tracking branch 'origin/main' into stranzhay/mip-11-3
stranzhay Feb 13, 2026
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
11,515 changes: 6,300 additions & 5,215 deletions clients/js/pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion clients/js/src/generated/instructions/createGroupV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type CreateGroupV1InstructionAccounts = {
/** The address of the new group */
group: Signer;
/** The authority of the new group */
updateAuthority?: PublicKey | Pda;
updateAuthority?: Signer;
/** The account paying for the storage fees */
payer?: Signer;
/** The system program */
Expand Down
9 changes: 8 additions & 1 deletion clients/js/test/_setupRaw.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
import {
assertAccountExists,
deserializeAccount,
generateSigner,
PublicKey,
publicKey,
Expand Down Expand Up @@ -30,6 +31,7 @@ import {
PluginAuthorityPairArgs,
UpdateAuthority,
} from '../src';
import { getGroupV1AccountDataSerializer } from '../src/hooked';

export const createUmi = async () => (await basecreateUmi()).use(mplCore());

Expand Down Expand Up @@ -321,7 +323,12 @@ export const assertGroup = async (
const { group, name, uri, updateAuthority, ...rest } = input;

const groupAddress = publicKey(group);
const groupWithPlugins = await fetchGroupV1(umi, groupAddress);
const maybeGroupAccount = await umi.rpc.getAccount(groupAddress);
assertAccountExists(maybeGroupAccount, 'GroupV1');
const groupWithPlugins = deserializeAccount(
maybeGroupAccount,
getGroupV1AccountDataSerializer()
);

// Name.
if (typeof name === 'string') t.is(groupWithPlugins.name, name);
Expand Down
81 changes: 47 additions & 34 deletions clients/js/test/approveGroupPluginAuthority.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { generateSigner, publicKey } from '@metaplex-foundation/umi';
import {
assertAccountExists,
generateSigner,
publicKey,
} from '@metaplex-foundation/umi';
import test from 'ava';
import {
addGroupPlugin,
approveGroupPluginAuthority,
revokeGroupPluginAuthority,
updateGroupPlugin,
} from '../src';
import {
assertGroup,
Expand All @@ -20,7 +22,7 @@ const LOG_WRAPPER = publicKey('noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV');
// Approve & Revoke Plugin Authority
// -----------------------------------------------------------------------------

test('it can approve and subsequently revoke a plugin authority on a group', async (t) => {
test('it can approve plugin authority on a group', async (t: any) => {
// ---------------------------------------------------------------------------
// Setup: create a group with an Attributes plugin whose authority is None.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -51,51 +53,62 @@ test('it can approve and subsequently revoke a plugin authority on a group', asy
logWrapper: LOG_WRAPPER,
}).sendAndConfirm(umi);

// The new authority should now be able to update the plugin.
await updateGroupPlugin(umi, {
group: group.publicKey,
payer: umi.identity,
authority: umi.identity,
logWrapper: LOG_WRAPPER,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'k1', value: 'v1' }],
},
}).sendAndConfirm(umi);

await assertGroup(t, umi, {
...DEFAULT_GROUP,
group: group.publicKey,
updateAuthority: umi.identity.publicKey,
});
});

test('re-approving group plugin authority with the same authority shape does not resize the account', async (t: any) => {
// ---------------------------------------------------------------------------
// Revoke: remove the dedicated authority and ensure it can no longer act.
// Setup: create a group and install an Attributes plugin.
// ---------------------------------------------------------------------------
await revokeGroupPluginAuthority(umi, {
const umi = await createUmi();
const group = await createGroup(umi);

await addGroupPlugin(umi, {
group: group.publicKey,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'init', value: 'value' }],
},
authority: umi.identity,
logWrapper: LOG_WRAPPER,
}).sendAndConfirm(umi);

// ---------------------------------------------------------------------------
// First approval: transition from default manager authority to Address authority.
// ---------------------------------------------------------------------------
const firstAuthority = generateSigner(umi);
await approveGroupPluginAuthority(umi, {
group: group.publicKey,
payer: umi.identity,
authority: umi.identity,
plugin: { type: 'Attributes' },
newAuthority: { type: 'Address', address: firstAuthority.publicKey },
logWrapper: LOG_WRAPPER,
}).sendAndConfirm(umi);

await assertGroup(t, umi, {
const firstApprovalAccount = await umi.rpc.getAccount(group.publicKey);
assertAccountExists(firstApprovalAccount, 'Group');
const accountSizeAfterFirstApproval = firstApprovalAccount.data.length;

// ---------------------------------------------------------------------------
// Second approval: another Address authority has the same serialized size.
// Account size must remain unchanged.
// ---------------------------------------------------------------------------
const secondAuthority = generateSigner(umi);
await approveGroupPluginAuthority(umi, {
group: group.publicKey,
updateAuthority: umi.identity.publicKey,
});
payer: umi.identity,
authority: umi.identity,
plugin: { type: 'Attributes' },
newAuthority: { type: 'Address', address: secondAuthority.publicKey },
logWrapper: LOG_WRAPPER,
}).sendAndConfirm(umi);

// Attempting an update with the revoked authority should now fail.
await t.throwsAsync(
updateGroupPlugin(umi, {
group: group.publicKey,
payer: umi.identity,
authority: newAuthority,
logWrapper: LOG_WRAPPER,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'fail', value: 'fail' }],
},
}).sendAndConfirm(umi)
);
const secondApprovalAccount = await umi.rpc.getAccount(group.publicKey);
assertAccountExists(secondApprovalAccount, 'Group');
t.is(secondApprovalAccount.data.length, accountSizeAfterFirstApproval);
});
30 changes: 28 additions & 2 deletions clients/js/test/closeGroup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'ava';
import { closeGroup } from '../src';
import { assertBurned, createGroup, createUmi } from './_setupRaw';
import { addAssetsToGroup, closeGroup } from '../src';
import { assertBurned, createAsset, createGroup, createUmi } from './_setupRaw';

test('it can close a group', async (t) => {
const umi = await createUmi();
Expand All @@ -12,3 +12,29 @@ test('it can close a group', async (t) => {

await assertBurned(t, umi, group.publicKey);
});

test('it cannot close a group with child assets', async (t) => {
const umi = await createUmi();
const group = await createGroup(umi);
const asset = await createAsset(umi, {});

await addAssetsToGroup(umi, {
group: group.publicKey,
authority: umi.identity,
})
.addRemainingAccounts([
{
isSigner: false,
isWritable: true,
pubkey: asset.publicKey,
},
])
.sendAndConfirm(umi);

await t.throwsAsync(
closeGroup(umi, {
group: group.publicKey,
}).sendAndConfirm(umi),
{ name: 'GroupMustBeEmpty' }
);
});
133 changes: 132 additions & 1 deletion clients/js/test/createGroup.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import test from 'ava';
import { generateSigner, publicKey } from '@metaplex-foundation/umi';
import {
addGroupPlugin,
createGroupV1,
RelationshipKind,
updateGroupPlugin,
} from '../src';
import {
assertGroup,
createGroup,
Expand All @@ -7,8 +14,9 @@ import {
} from './_setupRaw';

// Verify creation of a group
const LOG_WRAPPER = publicKey('noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV');

test('it can create a new group', async (t) => {
test('it can create a new group', async (t: any) => {
const umi = await createUmi();
const group = await createGroup(umi, {
name: 'My Group',
Expand All @@ -21,3 +29,126 @@ test('it can create a new group', async (t) => {
updateAuthority: umi.identity.publicKey,
});
});

test('it rejects creating a group with itself as a child relationship', async (t: any) => {
const umi = await createUmi();
const group = generateSigner(umi);

await t.throwsAsync(
createGroupV1(umi, {
name: 'Self Child Group',
uri: DEFAULT_GROUP.uri,
group,
payer: umi.identity,
relationships: [
{ kind: RelationshipKind.ChildGroup, key: group.publicKey },
],
})
.addRemainingAccounts([
{ isSigner: false, isWritable: true, pubkey: group.publicKey },
])
.sendAndConfirm(umi),
{ name: 'IncorrectAccount' }
);
});

test('it rejects creating a group with itself as a parent relationship', async (t: any) => {
const umi = await createUmi();
const group = generateSigner(umi);

await t.throwsAsync(
createGroupV1(umi, {
name: 'Self Parent Group',
uri: DEFAULT_GROUP.uri,
group,
payer: umi.identity,
relationships: [
{ kind: RelationshipKind.ParentGroup, key: group.publicKey },
],
})
.addRemainingAccounts([
{ isSigner: false, isWritable: true, pubkey: group.publicKey },
])
.sendAndConfirm(umi),
{ name: 'IncorrectAccount' }
);
});

test(
'it preserves plugin metadata on linked child and parent groups',
async (t: any) => {
const umi = await createUmi();
const childGroup = await createGroup(umi, { name: 'Child Group' });
const parentGroup = await createGroup(umi, { name: 'Parent Group' });

await addGroupPlugin(umi, {
group: childGroup.publicKey,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'child-key', value: 'child-value' }],
},
authority: umi.identity,
logWrapper: LOG_WRAPPER,
}).sendAndConfirm(umi);

await addGroupPlugin(umi, {
group: parentGroup.publicKey,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'parent-key', value: 'parent-value' }],
},
authority: umi.identity,
logWrapper: LOG_WRAPPER,
}).sendAndConfirm(umi);

const newGroup = generateSigner(umi);
await createGroupV1(umi, {
name: 'Group With Relations',
uri: DEFAULT_GROUP.uri,
group: newGroup,
payer: umi.identity,
relationships: [
{ kind: RelationshipKind.ChildGroup, key: childGroup.publicKey },
{ kind: RelationshipKind.ParentGroup, key: parentGroup.publicKey },
],
})
.addRemainingAccounts([
{ isSigner: false, isWritable: true, pubkey: childGroup.publicKey },
{ isSigner: false, isWritable: true, pubkey: parentGroup.publicKey },
])
.sendAndConfirm(umi);

await assertGroup(t, umi, {
group: childGroup.publicKey,
updateAuthority: umi.identity.publicKey,
parentGroups: [newGroup.publicKey],
});
await assertGroup(t, umi, {
group: parentGroup.publicKey,
updateAuthority: umi.identity.publicKey,
groups: [newGroup.publicKey],
});

// These updates must continue to succeed after linking relationships.
// If CreateGroupV1 corrupts plugin metadata, either call fails.
await updateGroupPlugin(umi, {
group: childGroup.publicKey,
authority: umi.identity,
logWrapper: LOG_WRAPPER,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'child-updated', value: 'child-updated-value' }],
},
}).sendAndConfirm(umi);

await updateGroupPlugin(umi, {
group: parentGroup.publicKey,
authority: umi.identity,
logWrapper: LOG_WRAPPER,
plugin: {
type: 'Attributes',
attributeList: [{ key: 'parent-updated', value: 'parent-updated-value' }],
},
}).sendAndConfirm(umi);
}
);
Loading