Skip to content

Commit 2181404

Browse files
authored
Fix update shrink account reallocation (#285)
Move plugin bytes before truncating accounts during asset and collection updates so shrinking name or URI data cannot invalidate the source range.
1 parent b3e9956 commit 2181404

2 files changed

Lines changed: 235 additions & 6 deletions

File tree

clients/rust/tests/plugin_shrink_corruption.rs

Lines changed: 219 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ use mpl_core::{
77
accounts::BaseAssetV1,
88
fetch_external_plugin_adapter_data_info,
99
instructions::{
10-
AddExternalPluginAdapterV1Builder, UpdatePluginV1Builder,
11-
WriteExternalPluginAdapterDataV1Builder,
10+
AddExternalPluginAdapterV1Builder, UpdateCollectionV1Builder, UpdatePluginV1Builder,
11+
UpdateV1Builder, WriteExternalPluginAdapterDataV1Builder,
1212
},
1313
types::{
14-
AppDataInitInfo, Attribute, Attributes, ExternalPluginAdapterInitInfo,
14+
AppDataInitInfo, Attribute, Attributes, Creator, ExternalPluginAdapterInitInfo,
1515
ExternalPluginAdapterKey, ExternalPluginAdapterSchema, FreezeDelegate, Plugin,
16-
PluginAuthority, PluginAuthorityPair,
16+
PluginAuthority, PluginAuthorityPair, Royalties, RuleSet,
1717
},
18-
Asset,
18+
Asset, Collection,
1919
};
2020
pub use setup::*;
2121

@@ -790,3 +790,217 @@ async fn test_update_plugin_shrink_attributes_preserves_external_plugin() {
790790
);
791791
}
792792
}
793+
794+
// Regression: shrinking asset name + uri must preserve attached plugin data.
795+
#[tokio::test]
796+
async fn test_update_v1_shrink_name_uri_preserves_plugin() {
797+
let mut context = program_test().start_with_context().await;
798+
799+
let asset = Keypair::new();
800+
801+
let long_name = "x".repeat(100);
802+
let long_uri = format!("https://example.com/{}", "y".repeat(200));
803+
804+
create_asset(
805+
&mut context,
806+
CreateAssetHelperArgs {
807+
owner: None,
808+
payer: None,
809+
asset: &asset,
810+
data_state: None,
811+
name: Some(long_name.clone()),
812+
uri: Some(long_uri.clone()),
813+
authority: None,
814+
update_authority: None,
815+
collection: None,
816+
plugins: vec![PluginAuthorityPair {
817+
plugin: Plugin::FreezeDelegate(FreezeDelegate { frozen: false }),
818+
authority: None,
819+
}],
820+
external_plugin_adapters: vec![],
821+
},
822+
)
823+
.await
824+
.unwrap();
825+
826+
let account_before = context
827+
.banks_client
828+
.get_account(asset.pubkey())
829+
.await
830+
.unwrap()
831+
.unwrap();
832+
let size_before = account_before.data.len();
833+
println!("Account size before name/uri shrink: {}", size_before);
834+
835+
let asset_before = Asset::from_bytes(&account_before.data).unwrap();
836+
assert_eq!(asset_before.base.name, long_name);
837+
assert_eq!(asset_before.base.uri, long_uri);
838+
assert!(asset_before.plugin_list.freeze_delegate.is_some());
839+
840+
let short_name = "a".to_string();
841+
let short_uri = "b".to_string();
842+
843+
let ix = UpdateV1Builder::new()
844+
.asset(asset.pubkey())
845+
.payer(context.payer.pubkey())
846+
.new_name(short_name.clone())
847+
.new_uri(short_uri.clone())
848+
.instruction();
849+
850+
let tx = Transaction::new_signed_with_payer(
851+
&[ix],
852+
Some(&context.payer.pubkey()),
853+
&[&context.payer],
854+
context.last_blockhash,
855+
);
856+
857+
context
858+
.banks_client
859+
.process_transaction(tx)
860+
.await
861+
.expect("Asset name/uri shrink transaction should succeed");
862+
863+
let account_after = context
864+
.banks_client
865+
.get_account(asset.pubkey())
866+
.await
867+
.unwrap()
868+
.unwrap();
869+
let size_after = account_after.data.len();
870+
assert!(
871+
size_after < size_before,
872+
"Expected account to shrink from {} to {}, but it did not",
873+
size_before,
874+
size_after
875+
);
876+
877+
let asset_after = Asset::from_bytes(&account_after.data).expect(
878+
"Asset deserialization should succeed after name/uri shrink; registry must remain intact",
879+
);
880+
881+
assert_eq!(asset_after.base.name, short_name, "Name should be updated");
882+
assert_eq!(asset_after.base.uri, short_uri, "URI should be updated");
883+
884+
let fd = asset_after
885+
.plugin_list
886+
.freeze_delegate
887+
.as_ref()
888+
.expect("FreezeDelegate must still be present after name/uri shrink");
889+
assert_eq!(
890+
fd.freeze_delegate,
891+
FreezeDelegate { frozen: false },
892+
"FreezeDelegate state should be unchanged"
893+
);
894+
}
895+
896+
// Regression: shrinking collection name + uri must preserve attached plugin data.
897+
#[tokio::test]
898+
async fn test_update_collection_v1_shrink_name_uri_preserves_plugin() {
899+
let mut context = program_test().start_with_context().await;
900+
901+
let collection = Keypair::new();
902+
903+
let long_name = "c".repeat(100);
904+
let long_uri = format!("https://example.com/collection/{}", "d".repeat(200));
905+
906+
let royalties = Royalties {
907+
basis_points: 500,
908+
creators: vec![Creator {
909+
address: context.payer.pubkey(),
910+
percentage: 100,
911+
}],
912+
rule_set: RuleSet::None,
913+
};
914+
915+
create_collection(
916+
&mut context,
917+
CreateCollectionHelperArgs {
918+
collection: &collection,
919+
update_authority: None,
920+
payer: None,
921+
name: Some(long_name.clone()),
922+
uri: Some(long_uri.clone()),
923+
plugins: vec![PluginAuthorityPair {
924+
plugin: Plugin::Royalties(royalties.clone()),
925+
authority: None,
926+
}],
927+
external_plugin_adapters: vec![],
928+
},
929+
)
930+
.await
931+
.unwrap();
932+
933+
let account_before = context
934+
.banks_client
935+
.get_account(collection.pubkey())
936+
.await
937+
.unwrap()
938+
.unwrap();
939+
let size_before = account_before.data.len();
940+
println!("Collection size before name/uri shrink: {}", size_before);
941+
942+
let collection_before = Collection::from_bytes(&account_before.data).unwrap();
943+
assert_eq!(collection_before.base.name, long_name);
944+
assert_eq!(collection_before.base.uri, long_uri);
945+
assert!(collection_before.plugin_list.royalties.is_some());
946+
947+
let short_name = "e".to_string();
948+
let short_uri = "f".to_string();
949+
950+
let ix = UpdateCollectionV1Builder::new()
951+
.collection(collection.pubkey())
952+
.payer(context.payer.pubkey())
953+
.new_name(short_name.clone())
954+
.new_uri(short_uri.clone())
955+
.instruction();
956+
957+
let tx = Transaction::new_signed_with_payer(
958+
&[ix],
959+
Some(&context.payer.pubkey()),
960+
&[&context.payer],
961+
context.last_blockhash,
962+
);
963+
964+
context
965+
.banks_client
966+
.process_transaction(tx)
967+
.await
968+
.expect("Collection name/uri shrink transaction should succeed");
969+
970+
let account_after = context
971+
.banks_client
972+
.get_account(collection.pubkey())
973+
.await
974+
.unwrap()
975+
.unwrap();
976+
let size_after = account_after.data.len();
977+
assert!(
978+
size_after < size_before,
979+
"Expected collection to shrink from {} to {}, but it did not",
980+
size_before,
981+
size_after
982+
);
983+
984+
let collection_after = Collection::from_bytes(&account_after.data).expect(
985+
"Collection deserialization should succeed after name/uri shrink; registry must remain intact",
986+
);
987+
988+
assert_eq!(
989+
collection_after.base.name, short_name,
990+
"Collection name should be updated"
991+
);
992+
assert_eq!(
993+
collection_after.base.uri, short_uri,
994+
"Collection URI should be updated"
995+
);
996+
997+
let royalties_after = collection_after
998+
.plugin_list
999+
.royalties
1000+
.as_ref()
1001+
.expect("Royalties plugin must still be present after name/uri shrink");
1002+
assert_eq!(
1003+
royalties_after.royalties, royalties,
1004+
"Royalties plugin contents should be unchanged"
1005+
);
1006+
}

programs/mpl-core/src/processor/update.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,11 +383,20 @@ fn process_update<'a, T: DataBlob + SolanaAccount>(
383383
.checked_add(size_diff)
384384
.ok_or(MplCoreError::NumericalOverflow)?;
385385

386-
resize_or_reallocate_account(account, payer, system_program, new_size as usize)?;
386+
if size_diff > 0 {
387+
// Growing: realloc first to make room for the rightward shift.
388+
resize_or_reallocate_account(account, payer, system_program, new_size as usize)?;
389+
}
387390

388391
let copy_len = (registry_offset as usize).saturating_sub(plugin_offset as usize);
389392

390393
if copy_len > 0 {
394+
// SAFETY: When growing, the account was resized above so the destination
395+
// region [new_plugin_offset, new_plugin_offset + copy_len) is in bounds.
396+
// When shrinking, the account is still the original size, so the source
397+
// region [plugin_offset, plugin_offset + copy_len) = [plugin_offset,
398+
// registry_offset) is in bounds. `sol_memmove` correctly handles
399+
// overlapping regions.
391400
unsafe {
392401
let base = account.data.borrow_mut().as_mut_ptr();
393402
sol_memmove(
@@ -398,6 +407,12 @@ fn process_update<'a, T: DataBlob + SolanaAccount>(
398407
}
399408
}
400409

410+
if size_diff < 0 {
411+
// Shrinking: realloc after memmove so the trailing plugin bytes are
412+
// preserved while the buffer still has its full pre-shrink length.
413+
resize_or_reallocate_account(account, payer, system_program, new_size as usize)?;
414+
}
415+
401416
plugin_header.save(account, new_core_size as usize)?;
402417

403418
// Move offsets for existing registry records.

0 commit comments

Comments
 (0)