From bce08e02cd66a563732fdeb7a186deb011101b4b Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Fri, 5 Jun 2026 15:13:19 +0200 Subject: [PATCH 1/5] identity_v2 smart contract poc --- Cargo.toml | 2 +- .../packages/iota_identity/Move.lock | 18 +- .../packages/iota_identity/Move.toml | 1 + .../iota_identity/sources/identity.move | 77 +++ .../sources/identity_v2/config.move | 239 +++++++ .../sources/identity_v2/identity_v2.move | 581 ++++++++++++++++++ .../identity_v2/migration_registry.move | 42 ++ .../sources/identity_v2/migration_tests.move | 113 ++++ .../sources/identity_v2/transaction.move | 59 ++ .../sources/multicontroller.move | 4 + .../sources/proposals/migrate.move | 32 + .../packages/iota_identity/tests/Cargo.toml | 14 + .../iota_identity/tests/src/identity.rs | 301 +++++++++ .../packages/iota_identity/tests/src/lib.rs | 33 + .../iota_identity/tests/tests/update_did.rs | 146 +++++ 15 files changed, 1652 insertions(+), 10 deletions(-) create mode 100644 identity_iota_core/packages/iota_identity/sources/identity_v2/config.move create mode 100644 identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move create mode 100644 identity_iota_core/packages/iota_identity/sources/identity_v2/migration_registry.move create mode 100644 identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move create mode 100644 identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move create mode 100644 identity_iota_core/packages/iota_identity/sources/proposals/migrate.move create mode 100644 identity_iota_core/packages/iota_identity/tests/Cargo.toml create mode 100644 identity_iota_core/packages/iota_identity/tests/src/identity.rs create mode 100644 identity_iota_core/packages/iota_identity/tests/src/lib.rs create mode 100644 identity_iota_core/packages/iota_identity/tests/tests/update_did.rs diff --git a/Cargo.toml b/Cargo.toml index 96252028a..f4405f6c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ "examples", ] -exclude = ["bindings/wasm/identity_wasm", "bindings/grpc"] +exclude = ["bindings/wasm/identity_wasm", "bindings/grpc", "identity_iota_core/packages/iota_identity/tests"] [workspace.dependencies] bls12_381_plus = { version = "0.8.17" } diff --git a/identity_iota_core/packages/iota_identity/Move.lock b/identity_iota_core/packages/iota_identity/Move.lock index 98642701c..cdcdafa5e 100644 --- a/identity_iota_core/packages/iota_identity/Move.lock +++ b/identity_iota_core/packages/iota_identity/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "C2AC50C3DADC20A9BC9C4473015BF689654F2549655BBCE287C1EA2477294A4F" +manifest_digest = "4F683992E583AF26EC3B6B905CAE1643F799A1B4F2D48E4BA5E48DA165952893" deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" dependencies = [ { id = "Iota", name = "Iota" }, @@ -13,7 +13,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e3c45c9370f14b8137fd1bb9d109d3563d416223", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "5b7be9f6824e2b7b8626b55fa5eb4853eb4dc102", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -21,7 +21,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e3c45c9370f14b8137fd1bb9d109d3563d416223", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "5b7be9f6824e2b7b8626b55fa5eb4853eb4dc102", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -30,11 +30,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e3c45c9370f14b8137fd1bb9d109d3563d416223", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "5b7be9f6824e2b7b8626b55fa5eb4853eb4dc102", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e3c45c9370f14b8137fd1bb9d109d3563d416223", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "5b7be9f6824e2b7b8626b55fa5eb4853eb4dc102", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -42,7 +42,7 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.18.0-beta" +compiler-version = "1.22.1" edition = "2024" flavor = "iota" @@ -61,9 +61,9 @@ latest-published-id = "0x8896ab04fe24c044c54925df3f8a7c383a8d1d6f6bbb95d1c57cfa9 published-version = "1" [env.localnet] -chain-id = "a6bad7ce" -original-published-id = "0x057742ffe67851bc2f0d31affcd23925cfc78633bb2c11ae53cacfafb0236f96" -latest-published-id = "0x057742ffe67851bc2f0d31affcd23925cfc78633bb2c11ae53cacfafb0236f96" +chain-id = "64333340" +original-published-id = "0x37233f30a3fd758a9146e19e39c750b9fa099313f9c87db663580a4fea40a392" +latest-published-id = "0x37233f30a3fd758a9146e19e39c750b9fa099313f9c87db663580a4fea40a392" published-version = "1" [env.mainnet] diff --git a/identity_iota_core/packages/iota_identity/Move.toml b/identity_iota_core/packages/iota_identity/Move.toml index 62776a7d6..7c2907e0b 100644 --- a/identity_iota_core/packages/iota_identity/Move.toml +++ b/identity_iota_core/packages/iota_identity/Move.toml @@ -9,6 +9,7 @@ edition = "2024" [addresses] iota_identity = "0x0" +identity_team = "0x1d3c717" # TODO: replace this with the actual address of the identity team. [dev-dependencies] diff --git a/identity_iota_core/packages/iota_identity/sources/identity.move b/identity_iota_core/packages/iota_identity/sources/identity.move index dc37adcca..41b9b5125 100644 --- a/identity_iota_core/packages/iota_identity/sources/identity.move +++ b/identity_iota_core/packages/iota_identity/sources/identity.move @@ -2,15 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 module iota_identity::identity { + use iota::authenticator_function::AuthenticatorFunctionRefV1; use iota::clock::Clock; + use iota::dynamic_field as df; use iota::transfer::Receiving; use iota::vec_map::{Self, VecMap}; + use iota_identity::aa_migration::{Self, AAMigrationProposal}; + use iota_identity::aa_migration_registry::AAMigrationRegistry; use iota_identity::access_sub_entity_proposal::{Self, AccessSubEntity}; use iota_identity::borrow_proposal::{Self, Borrow}; use iota_identity::config_proposal; use iota_identity::controller::{DelegationToken, ControllerCap}; use iota_identity::controller_proposal::{Self, ControllerExecution}; use iota_identity::delete_proposal::{Self, Delete}; + use iota_identity::identity_config::new as new_config; + use iota_identity::identity_v2::{Self, IdentityV2}; use iota_identity::multicontroller::{Self, Multicontroller, Action}; use iota_identity::transfer_proposal::{Self, Send}; use iota_identity::update_value_proposal::{Self, UpdateValue}; @@ -29,6 +35,8 @@ module iota_identity::identity { const ECannotDelete: u64 = 5; /// Identity had been deleted. const EDeletedIdentity: u64 = 6; + #[error(code = 7)] + const EThresholdNotReached: vector = b"Not enough voting power to execute proposal"; const PACKAGE_VERSION: u64 = 0; @@ -679,6 +687,10 @@ module iota_identity::identity { /// set to `true`. public fun delete(self: Identity) { assert!(self.deleted && self.did_doc.controllers().is_empty(), ECannotDelete); + self.force_delete(); + } + + public(package) fun force_delete(self: Identity) { let Identity { id, did_doc, @@ -688,6 +700,71 @@ module iota_identity::identity { did_doc.delete(); } + public struct AAMigrationKey has copy, drop, store {} + + /// Proposes or approves a migration to the AA-based implementation of this Identity object. + public fun propose_or_approve_aa_migration( + self: &mut Identity, + cap: &mut ControllerCap, + ctx: &mut TxContext, + ) { + assert!(!self.deleted, EDeletedIdentity); + let (token, borrow) = cap.borrow(); + self.did_doc.assert_is_member(&token); + + let proposal: &mut AAMigrationProposal = if (df::exists_(&self.id, AAMigrationKey {})) { + df::borrow_mut(&mut self.id, AAMigrationKey {}) + } else { + let proposal = aa_migration::new(); + df::add(&mut self.id, AAMigrationKey {}, proposal); + df::borrow_mut(&mut self.id, AAMigrationKey {}) + }; + + proposal.insert_controller(ctx.sender(), self.did_doc.voting_power(token.controller())); + cap.put_back(token, borrow); + } + + public fun execute_aa_migration( + mut self: Identity, + mut cap: ControllerCap, + migration_registry: &mut AAMigrationRegistry, + auth_fn: AuthenticatorFunctionRefV1, + ctx: &mut TxContext, + ) { + self.propose_or_approve_aa_migration(&mut cap, ctx); + let proposal: AAMigrationProposal = df::remove(&mut self.id, AAMigrationKey {}); + + let total_weights = proposal.weights().fold!(0, |acc, w| acc + w); + assert!(total_weights >= self.did_doc.threshold(), EThresholdNotReached); + + let mut permissions = vector::empty(); + proposal.controllers().do_ref!(|_| permissions.push_back(std::u64::max_value!())); + let config = new_config( + proposal.controllers(), + proposal.weights(), + permissions, + self.did_doc.threshold(), + ); + + let did_doc = identity_v2::new_did_document( + *self.did_doc.value().borrow(), + self.created, + self.updated, + self.deleted_did, + ); + let new_identity_id = identity_v2::new_from_parts( + did_doc, + config, + option::some(self.id().to_inner()), + auth_fn, + ctx, + ); + migration_registry.insert(self.id().to_inner(), new_identity_id); + + cap.delete(); + self.force_delete(); + } + public(package) fun uid_mut(self: &mut Identity): &mut UID { &mut self.id } diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/config.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/config.move new file mode 100644 index 000000000..67fb6c5a2 --- /dev/null +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/config.move @@ -0,0 +1,239 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +module iota_identity::identity_config; + +#[error(code = 0)] +const EThresholdZero: vector = b"Threshold must be greater than zero"; +#[error(code = 1)] +const EThresholdTooHigh: vector = + b"Threshold cannot be higher than the sum of all controller weights"; +#[error(code = 2)] +const EControllersComponentsHaveDifferentLengths: vector = + b"Controllers components must have the same length"; +#[error(code = 3)] +const EControllersMustNotContainDuplicated: vector = b"Controllers must not contain duplicates"; +#[error(code = 4)] +const EControllerNotFound: vector = b"Controller not found"; + +public struct Controller has drop, store { + addr: address, + weight: u64, + permissions: u64, +} + +/// Returns the address of the controller. +public fun addr(self: &Controller): address { + self.addr +} + +/// Returns the weight of the controller. +public fun weight(self: &Controller): u64 { + self.weight +} + +/// Sets a new weight for the controller. +public fun set_weight(self: &mut Controller, new_weight: u64) { + self.weight = new_weight; +} + +/// Returns the permissions of the controller. +public fun permissions(self: &Controller): u64 { + self.permissions +} + +/// Sets new permissions for the controller. +public fun set_permissions(self: &mut Controller, new_permissions: u64) { + self.permissions = new_permissions; +} + +/// Returns true if the controller has the specified permission or is an admin. +public fun has_permission(self: &Controller, permission: u64): bool { + (self.permissions & permission) != 0 || (self.permissions & admin!()) != 0 +} + +/// Identity configuration. Contains the list of controllers and the threshold. +public struct IdentityConfig has drop, store { + controllers: vector, + threshold: u64, +} + +/// Create a new `IdentityConfig`. +/// - `addresses` and `weights` must have the same length. +/// - `addresses` must not contain duplicates. +/// - `threshold` must be greater than zero and less than or equal to the sum of all weights. +public fun new( + addresses: vector
, + weights: vector, + permissions: vector, + threshold: u64, +): IdentityConfig { + check_controllers(&addresses, &weights, &permissions); + + let total_weight = weights.fold!(0, |total, w| total + w); + let mut i = 0; + let mut controllers = vector::empty(); + while (i < permissions.length()) { + controllers.push_back(Controller { + addr: addresses[i], + weight: weights[i], + permissions: permissions[i], + }); + i = i + 1; + }; + assert!(threshold > 0, EThresholdZero); + assert!(threshold <= total_weight, EThresholdTooHigh); + + IdentityConfig { controllers, threshold } +} + +/// Returns a reference to the controllers of the `IdentityConfig`. +public fun controllers(self: &IdentityConfig): &vector { + &self.controllers +} + +/// Returns the threshold of the `IdentityConfig`. +public fun threshold(self: &IdentityConfig): u64 { + self.threshold +} + +/// Checks if the given address is a controller of the `IdentityConfig`. +public fun contains(self: &IdentityConfig, addr: address): bool { + self.controllers.find_index!(|controller| controller.addr == addr).is_some() +} + +/// Returns a reference to the controller with the given address. +public fun borrow_controller(self: &IdentityConfig, controller: address): &Controller { + let idx = self.controllers.find_index!(|c| c.addr == controller); + assert!(idx.is_some(), EControllerNotFound); + + self.controllers.borrow(idx.destroy_some()) +} + +/// Returns a mutable reference to the controller with the given address. +public fun borrow_controller_mut(self: &mut IdentityConfig, controller: address): &mut Controller { + let idx = self.controllers.find_index!(|c| c.addr == controller); + assert!(idx.is_some(), EControllerNotFound); + + self.controllers.borrow_mut(idx.destroy_some()) +} + +/// Adds a new controller to the `IdentityConfig`. +public fun add_controller(self: &mut IdentityConfig, addr: address, weight: u64, permissions: u64) { + assert!(!self.contains(addr), EControllersMustNotContainDuplicated); + self.controllers.push_back(Controller { addr, weight, permissions }); +} + +/// Removes a controller from the `IdentityConfig`. +/// - The controller must exist in the `IdentityConfig`. +/// - After removal, the threshold must still be valid +/// (i.e., less than or equal to the sum of the remaining controllers' weights). +public fun remove_controller(self: &mut IdentityConfig, addr: address) { + let idx = self.controllers.find_index!(|c| c.addr == addr); + assert!(idx.is_some(), EControllerNotFound); + + self.controllers.swap_remove(idx.destroy_some()); + + let total_weight = total_weight(&self.controllers); + assert!(self.threshold <= total_weight, EThresholdTooHigh); +} + +/// Updates the weight of an existing controller in the `IdentityConfig`. +public fun update_controller_weight( + self: &mut IdentityConfig, + addr: address, + new_weight: u64, + new_permissions: u64, +) { + let controller = self.borrow_controller_mut(addr); + controller.weight = new_weight; + controller.permissions = new_permissions; + + let total_weight = total_weight(&self.controllers); + assert!(self.threshold <= total_weight, EThresholdTooHigh); +} + +/// Sets a new threshold for the `IdentityConfig`. +public fun set_threshold(self: &mut IdentityConfig, new_threshold: u64) { + let total_weight = total_weight(&self.controllers); + assert!(new_threshold > 0, EThresholdZero); + assert!(new_threshold <= total_weight, EThresholdTooHigh); + + self.threshold = new_threshold; +} + +fun total_weight(controllers: &vector): u64 { + let mut total = 0; + controllers.do_ref!(|controller| { total = total + controller.weight; }); + total +} + +// Validates the controllers' components. +fun check_controllers( + addresses: &vector
, + weights: &vector, + permissions: &vector, +) { + // Check that the lengths of the provided vectors are equal. + assert!(addresses.length() == weights.length(), EControllersComponentsHaveDifferentLengths); + assert!(addresses.length() == permissions.length(), EControllersComponentsHaveDifferentLengths); + + // Check that the provided addresses are unique. + let mut seen = vector::empty
(); + addresses.do_ref!(|addr| { + assert!(!seen.contains(addr), EControllersMustNotContainDuplicated); + seen.push_back(*addr); + }); +} + +public fun is_subset_of(permissions: u64, other_permissions: u64): bool { + (permissions & other_permissions.bitwise_not()) == 0 +} + +public macro fun can_propose_tx(): u64 { + 1 << 0 +} + +public macro fun can_approve_tx(): u64 { + 1 << 1 +} + +public macro fun can_execute_tx(): u64 { + 1 << 2 +} + +public macro fun can_update_did(): u64 { + 1 << 3 +} + +public macro fun can_deactivate_did(): u64 { + 1 << 4 +} + +public macro fun can_delete_did(): u64 { + 1 << 5 +} + +public macro fun can_add_controller(): u64 { + 1 << 6 +} + +public macro fun can_update_controller(): u64 { + 1 << 7 +} + +public macro fun can_remove_controller(): u64 { + 1 << 8 +} + +public macro fun can_update_threshold(): u64 { + 1 << 9 +} + +public macro fun can_transfer_asset(): u64 { + 1 << 10 +} + +public macro fun admin(): u64 { + 1 << 63 +} diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move new file mode 100644 index 000000000..e4da538a1 --- /dev/null +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move @@ -0,0 +1,581 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +module iota_identity::identity_v2; + +use iota::account::create_account_v1; +use iota::authenticator_function::AuthenticatorFunctionRefV1; +use iota::bcs; +use iota::clock::Clock; +use iota::dynamic_field as df; +use iota::ecdsa_k1::secp256k1_verify; +use iota::ecdsa_r1::secp256r1_verify; +use iota::ed25519::ed25519_verify; +use iota::hash::blake2b256; +use iota::object::id_from_bytes; +use iota::ptb_call_arg::CallArg; +use iota::ptb_command::{Command, ProgrammableMoveCall}; +use iota::table::{Self, Table}; +use iota_identity::identity_config::{Self as config, IdentityConfig, Controller}; +use iota_identity::transaction::{Self, Transactions}; + +#[error(code = 0)] +const ESenderNotIdentity: vector = b"Sender must be the identity itself"; +#[error(code = 1)] +const EDeletedDidDocument: vector = b"DID Document has been deleted"; +#[error(code = 2)] +const EInvalidAuthenticatorFunction: vector = b"Invalid authenticator function"; +#[error(code = 3)] +const EInsufficientApprovals: vector = b"Insufficient approvals for this transaction"; +#[error(code = 4)] +const EInvalidControllerSignature: vector = b"Invalid controller signature"; +#[error(code = 5)] +const EUnsupportedKeyType: vector = b"Unsupported key type"; +#[error(code = 6)] +const ETransactionDigestMismatch: vector = b"Transaction digest mismatch"; +#[error(code = 7)] +const EInsufficientPermissions: vector = b"Controller does not have sufficient permissions"; +#[error(code = 8)] +const EInvalidParameter: vector = b"An invalid parameter was provided to the function"; +#[error(code = 9)] +const ENotASubIdentity: vector = + b"The provided identity is not a sub-identity of this identity"; + +public struct IdentityV2 has key { + id: UID, +} + +/// Creates a new Identity with the given DID Document. +/// The sender of the trasaction will become the single controller of the Identity +/// with a weight of 1, all permisssions, and the threshold will be set to 1, meaning that the controller +/// alone can update the DID Document in the future. +public fun new( + did_document: vector, + auth_fn: AuthenticatorFunctionRefV1, + clock: &Clock, + ctx: &mut TxContext, +) { + new_with_config( + did_document, + vector[ctx.sender()], + vector[1], + vector[std::u64::max_value!()], + 1, + auth_fn, + clock, + ctx, + ) +} + +/// Creates a new Identity with the given DID Document and configuration of controllers, weights and threshold. +public fun new_with_config( + did_document: vector, + controllers: vector
, + weights: vector, + permissions: vector, + threshold: u64, + auth_fn: AuthenticatorFunctionRefV1, + clock: &Clock, + ctx: &mut TxContext, +) { + let now = clock.timestamp_ms(); + let config = config::new(controllers, weights, permissions, threshold); + let did_document = DidDocument { + document: did_document, + created: now, + updated: now, + deleted: false, + }; + + new_from_parts(did_document, config, option::none(), auth_fn, ctx); +} + +public(package) fun new_from_parts( + document: DidDocument, + config: IdentityConfig, + legacy_id: Option, + auth_fn: AuthenticatorFunctionRefV1, + ctx: &mut TxContext, +): ID { + // Ensure that the provided authenticator function is the one defined in this module. + validate_auth_fn(&auth_fn); + + let id = object::new(ctx); + let mut identity = IdentityV2 { id }; + let receipts_table: Table, address> = table::new(ctx); + + df::add(&mut identity.id, ConfigKey {}, config); + df::add(&mut identity.id, DidDocumentKey {}, document); + df::add(&mut identity.id, TransactionsKey {}, transaction::new(ctx)); + df::add( + &mut identity.id, + TxExecutionReceiptsKey {}, + receipts_table, + ); + + if (legacy_id.is_some()) { + df::add(&mut identity.id, LegacyIdKey {}, legacy_id.destroy_some()); + }; + + let identity_id = identity.id.to_inner(); + create_account_v1(identity, auth_fn); + identity_id +} + +/// Proposes a transaction for approval by the controllers of this Identity. +public fun propose_tx(self: &mut IdentityV2, tx_digest: vector, ctx: &mut TxContext) { + self.check_controller_permissions(ctx.sender(), config::can_propose_tx!()); + + let transactions: &mut Transactions = df::borrow_mut(&mut self.id, TransactionsKey {}); + transactions.insert(tx_digest); + + let tx = transactions.borrow_mut(&tx_digest); + tx.add_approver(ctx.sender()); +} + +/// Approves a transaction by the sender if it is a controller of this Identity. +public fun approve_tx(self: &mut IdentityV2, tx_digest: vector, ctx: &mut TxContext) { + self.check_controller_permissions(ctx.sender(), config::can_approve_tx!()); + + let transactions: &mut Transactions = df::borrow_mut(&mut self.id, TransactionsKey {}); + assert!(transactions.contains(&tx_digest), ETransactionDigestMismatch); + + let tx = transactions.borrow_mut(&tx_digest); + tx.add_approver(ctx.sender()); +} + +public fun id(self: &IdentityV2): ID { + self.id.to_inner() +} + +public fun borrow_uid(self: &IdentityV2): &UID { + &self.id +} + +public fun account_address(self: &IdentityV2): address { + self.id.to_address() +} + +/// Returns the DID Document of this Identity. +public fun did_document(self: &IdentityV2): DidDocument { + *df::borrow(&self.id, DidDocumentKey {}) +} + +/// Returns the Identity's configuration. +public fun borrow_config(self: &IdentityV2): &IdentityConfig { + df::borrow(&self.id, ConfigKey {}) +} + +/// Updates the DID Document of this Identity. +public fun update_did_document( + self: &mut IdentityV2, + document: vector, + clock: &Clock, + ctx: &mut TxContext, +) { + // This ensures that only the identity itself can update its DID Document, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + // TODO: validate the new DID Document (e.g., check the magic bytes, the encoding bytes, + // and that it contains a JSON object). + + // Update DID Document and its timestamps. + let now = clock.timestamp_ms(); + let document_meta = self.borrow_did_document_mut(); + + // Ensure that the DID Document has not been deleted. + assert!(!document_meta.deleted, EDeletedDidDocument); + + document_meta.document = document; + document_meta.updated = now; +} + +public fun deactivate_did_document(self: &mut IdentityV2, clock: &Clock, ctx: &mut TxContext) { + // This ensures that only the identity itself can update its DID Document, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let now = clock.timestamp_ms(); + let document_meta = self.borrow_did_document_mut(); + + // Ensure that the DID Document has not been deleted. + assert!(!document_meta.deleted, EDeletedDidDocument); + + document_meta.document = vector::empty(); + document_meta.updated = now; +} + +public fun delete_did_document(self: &mut IdentityV2, clock: &Clock, ctx: &mut TxContext) { + // This ensures that only the identity itself can update its DID Document, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let now = clock.timestamp_ms(); + let document_meta = self.borrow_did_document_mut(); + + // Ensure that the DID Document has not been deleted already. + assert!(!document_meta.deleted, EDeletedDidDocument); + + document_meta.document = vector::empty(); + document_meta.updated = now; + document_meta.deleted = true; +} + +/// Add a new controller to this Identity. +public fun add_controller( + self: &mut IdentityV2, + controller: address, + weight: u64, + permissions: u64, + ctx: &mut TxContext, +) { + // This ensures that only the identity itself can update its controltransactions_lers, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let config: &mut IdentityConfig = df::borrow_mut(&mut self.id, ConfigKey {}); + config.add_controller(controller, weight, permissions); +} + +/// Update the weight and permissions of an existing controller of this Identity. +public fun update_controller( + self: &mut IdentityV2, + addr: address, + weight: u64, + permissions: u64, + ctx: &mut TxContext, +) { + // This ensures that only the identity itself can update its controllers, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let config: &mut IdentityConfig = df::borrow_mut(&mut self.id, ConfigKey {}); + let controller = config.borrow_controller_mut(addr); + controller.set_weight(weight); + controller.set_permissions(permissions); +} + +/// Remove a controller from this Identity. +public fun remove_controller(self: &mut IdentityV2, controller: address, ctx: &mut TxContext) { + // This ensures that only the identity itself can update its controllers, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let config: &mut IdentityConfig = df::borrow_mut(&mut self.id, ConfigKey {}); + config.remove_controller(controller); +} + +/// Update the approval threshold of this Identity. +public fun update_threshold(self: &mut IdentityV2, new_threshold: u64, ctx: &mut TxContext) { + // This ensures that only the identity itself can update its threshold, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let config: &mut IdentityConfig = df::borrow_mut(&mut self.id, ConfigKey {}); + config.set_threshold(new_threshold); +} + +/// Adds a transaction execution receipt to the sub-identity `sub_identity` for the transaction with digest `tx_digest`. +public fun add_tx_execution_receipt( + self: &IdentityV2, + sub_identity: &mut IdentityV2, + tx_digest: vector, + ctx: &mut TxContext, +) { + // This ensures that only the sub-identity itself can add a transaction execution receipt, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == sub_identity.account_address(), ESenderNotIdentity); + + let sub_identity_config: &IdentityConfig = df::borrow(&sub_identity.id, ConfigKey {}); + assert!(sub_identity_config.contains(self.account_address()), ENotASubIdentity); + + let receipts_table: &mut Table, address> = df::borrow_mut( + &mut sub_identity.id, + TxExecutionReceiptsKey {}, + ); + receipts_table.add(tx_digest, self.account_address()); +} + +public fun legacy_id(self: &IdentityV2): Option { + if (df::exists_with_type<_, ID>(&self.id, LegacyIdKey {})) { + option::some(*df::borrow(&self.id, LegacyIdKey {})) + } else { + option::none() + } +} + +fun borrow_did_document_mut(self: &mut IdentityV2): &mut DidDocument { + df::borrow_mut(&mut self.id, DidDocumentKey {}) +} + +fun check_controller_permissions( + identity: &IdentityV2, + controller: address, + required_permissions: u64, +) { + let config: &IdentityConfig = df::borrow(&identity.id, ConfigKey {}); + let controller_info = config.borrow_controller(controller); + assert!( + (controller_info.permissions() & required_permissions) == required_permissions, + EInsufficientPermissions, + ); +} + +public struct DidDocument has copy, drop, store { + document: vector, + created: u64, + updated: u64, + deleted: bool, +} + +public(package) fun new_did_document( + document: vector, + created: u64, + updated: u64, + deleted: bool, +): DidDocument { + DidDocument { + document, + created, + updated, + deleted, + } +} + +public struct ConfigKey has copy, drop, store {} + +public struct DidDocumentKey has copy, drop, store {} + +public struct LegacyIdKey has copy, drop, store {} + +public struct TransactionsKey has copy, drop, store {} + +public struct TxExecutionReceiptsKey has copy, drop, store {} + +#[authenticator] +public fun authenticate_v1( + identity: &IdentityV2, + controller_sig: Option>, + controller_pk: Option>, + auth_ctx: &AuthContext, + ctx: &TxContext, +) { + // This ensures that only the identity itself can update its DID Document, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == identity.account_address(), ESenderNotIdentity); + let config: &IdentityConfig = df::borrow(&identity.id, ConfigKey {}); + let transactions: &Transactions = df::borrow(&identity.id, TransactionsKey {}); + // Extract the invoking controller from the provided authentication parameters and validate the authenticity of the invocation. + let controller = if (controller_sig.is_some() && controller_pk.is_some()) { + let controller_sig = controller_sig.destroy_some(); + let controller_pk = controller_pk.destroy_some(); + validate_controller_signature(&controller_pk, &controller_sig, config, ctx.digest()) + } else { + let receipt_table = df::borrow(&identity.id, TxExecutionReceiptsKey {}); + check_for_receipt(receipt_table, config, ctx.digest()) + }; + + identity.check_controller_permissions(controller.addr(), config::can_execute_tx!()); + + // Count the total approvals for this transaction. + let mut largest_weight = controller.weight(); + let mut approvals = controller.weight(); + let mut comulative_permissions = controller.permissions(); + if (transactions.contains(ctx.digest())) { + let tx = transactions.borrow(ctx.digest()); + tx.approvers().do_ref!(|addr| { + if (addr != controller.addr()) { + let controller = config.borrow_controller(*addr); + approvals = approvals + controller.weight(); + comulative_permissions = comulative_permissions | controller.permissions(); + + if (controller.weight() > largest_weight) { + largest_weight = controller.weight(); + } + } + }); + }; + assert!(approvals >= config.threshold(), EInsufficientApprovals); + + validate_commands( + auth_ctx.tx_inputs(), + auth_ctx.tx_commands(), + config, + comulative_permissions, + largest_weight, + ); +} + +fun validate_auth_fn(auth_fn: &AuthenticatorFunctionRefV1) { + assert!( + auth_fn.package() == identity_v2_pkg_id() + && auth_fn.module_name().as_bytes() == b"identity_v2" + && auth_fn.function_name().as_bytes() == b"authenticate_v1", + EInvalidAuthenticatorFunction, + ) +} + +fun check_for_receipt( + receipts_table: &Table, address>, + config: &IdentityConfig, + digest: &vector, +): &Controller { + let controller_address = *receipts_table.borrow(*digest); + let controller = config.borrow_controller(controller_address); + + controller +} + +fun validate_controller_signature( + controller_pk: &vector, + controller_sig: &vector, + config: &IdentityConfig, + digest: &vector, +): &Controller { + let mut pk_bytes = *controller_pk; + match (controller_pk[0]) { + 0 => { + let mut pk_tag_removed = *controller_pk; + pk_tag_removed.remove(0); + pk_bytes = pk_tag_removed; + assert!( + ed25519_verify(controller_sig, &pk_tag_removed, digest), + EInvalidControllerSignature, + ); + }, + 1 => { + assert!( + secp256r1_verify(controller_sig, controller_pk, digest, 0), + EInvalidControllerSignature, + ); + }, + 2 => { + assert!( + secp256k1_verify(controller_sig, controller_pk, digest, 0), + EInvalidControllerSignature, + ); + }, + _ => { + assert!(false, EUnsupportedKeyType); + }, + }; + let controller_address = id_from_bytes(blake2b256(&pk_bytes)).to_address(); + config.borrow_controller(controller_address) +} + +fun validate_commands( + inputs: &vector, + commands: &vector, + config: &IdentityConfig, + permissions: u64, + weight: u64, +) { + // If an admin controller approved this transaction, we skip the validation. + if (permissions & config::admin!() != 0) { + return + }; + + commands.do_ref!(|cmd| { + if (cmd.is_move_call()) { + let move_call = cmd.as_move_call().destroy_some(); + if (move_call_is(&move_call, b"update_did_document")) { + assert_permissions(permissions, config::can_update_did!()); + } else if (move_call_is(&move_call, b"deactivate_did_document")) { + assert_permissions(permissions, config::can_deactivate_did!()); + } else if (move_call_is(&move_call, b"delete_did_document")) { + assert_permissions(permissions, config::can_delete_did!()); + } else if (move_call_is(&move_call, b"add_controller")) { + validate_add_controller_call(&move_call, inputs, permissions, weight); + } else if (move_call_is(&move_call, b"update_controller")) { + validate_update_controller_call(&move_call, inputs, config, permissions, weight); + } else if (move_call_is(&move_call, b"remove_controller")) { + validate_remove_controller_call(&move_call, inputs, config, permissions, weight); + } else if (move_call_is(&move_call, b"update_threshold")) { + assert_permissions(permissions, config::can_update_threshold!()); + } else {} + } else if (cmd.is_transfer_objects()) { + let _transfer = cmd.as_transfer_objects().destroy_some(); + assert_permissions(permissions, config::can_transfer_asset!()); + // TODO: perform additional validations. + } + }); +} + +fun validate_add_controller_call( + move_call: &ProgrammableMoveCall, + inputs: &vector, + approvers_permissions: u64, + approvers_weight: u64, +) { + assert_permissions(approvers_permissions, config::can_add_controller!()); + + let input = inputs[move_call.arguments()[2].input_index().destroy_some() as u64]; + let proposed_weight = bcs::new(input.as_pure_data().destroy_some()).peel_u64(); + let input = inputs[move_call.arguments()[3].input_index().destroy_some() as u64]; + let proposed_permissions = bcs::new(input.as_pure_data().destroy_some()).peel_u64(); + + assert!(proposed_weight <= approvers_weight, EInsufficientPermissions); // No inflation above approvers. + assert_permissions_update(0, proposed_permissions, approvers_permissions); +} + +fun validate_update_controller_call( + move_call: &ProgrammableMoveCall, + inputs: &vector, + config: &IdentityConfig, + approvers_permissions: u64, + approvers_weight: u64, +) { + assert_permissions(approvers_permissions, config::can_update_controller!()); + + let input = inputs[move_call.arguments()[1].input_index().destroy_some() as u64]; + let target_controller_addr = bcs::new(input.as_pure_data().destroy_some()).peel_address(); + let input = inputs[move_call.arguments()[2].input_index().destroy_some() as u64]; + let new_weight = bcs::new(input.as_pure_data().destroy_some()).peel_u64(); + let input = inputs[move_call.arguments()[3].input_index().destroy_some() as u64]; + let new_permissions = bcs::new(input.as_pure_data().destroy_some()).peel_u64(); + let controller = config.borrow_controller(target_controller_addr); + + assert!(new_weight <= approvers_weight, EInsufficientPermissions); // No inflaction above approvers. + assert!(controller.weight() <= approvers_weight, EInsufficientPermissions); // No updates to controllers with higher weight than approvers. + assert_permissions_update(controller.permissions(), new_permissions, approvers_permissions); +} + +fun validate_remove_controller_call( + move_call: &ProgrammableMoveCall, + inputs: &vector, + config: &IdentityConfig, + approvers_permissions: u64, + approvers_weight: u64, +) { + assert_permissions(approvers_permissions, config::can_remove_controller!()); + let input = inputs[move_call.arguments()[1].input_index().destroy_some() as u64]; + let target_controller_addr = bcs::new(input.as_pure_data().destroy_some()).peel_address(); + let target_controller = config.borrow_controller(target_controller_addr); + assert!(target_controller.weight() <= approvers_weight, EInsufficientPermissions); + assert_permissions_update(target_controller.permissions(), 0, approvers_permissions); +} + +fun move_call_is(cmd: &ProgrammableMoveCall, function: vector): bool { + cmd.module_name().as_bytes() == b"identity_v2" && cmd.function().as_bytes() == function +} + +fun assert_permissions(permissions: u64, required_permissions: u64) { + assert!((permissions & required_permissions) == required_permissions, EInsufficientPermissions); +} + +fun assert_permissions_update( + current_permissions: u64, + new_permissions: u64, + approver_permissions: u64, +) { + assert!( + config::is_subset_of(current_permissions ^ new_permissions, approver_permissions), + EInsufficientPermissions, + ); +} + +fun identity_v2_pkg_id(): ID { + let type_name = std::type_name::get(); + iota::address::from_ascii_bytes(type_name.get_address().as_bytes()).to_id() +} diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_registry.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_registry.move new file mode 100644 index 000000000..bc0fa9d83 --- /dev/null +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_registry.move @@ -0,0 +1,42 @@ +module iota_identity::aa_migration_registry; + +use iota::table::{Self, Table}; +use iota_identity::controller::ControllerCap; + +#[error(code = 0)] +const ESenderNotIdentityTeam: vector = + b"Only the identity team can create the migration registry"; +#[error(code = 1)] +const EIdentityNotMigrated: vector = b"Referenced identity has not been migrated"; + +public struct AAMigrationRegistry has key { + id: UID, + migrated_identities: Table, +} + +public fun new(ctx: &mut TxContext) { + assert!(ctx.sender() == @identity_team, ESenderNotIdentityTeam); + let registry = AAMigrationRegistry { + id: object::new(ctx), + migrated_identities: table::new(ctx), + }; + + transfer::share_object(registry); +} + +public fun get(self: &AAMigrationRegistry, old_id: ID): Option { + if (self.migrated_identities.contains(old_id)) { + option::some(*self.migrated_identities.borrow(old_id)) + } else { + option::none() + } +} + +public fun delete_controller_cap(self: &AAMigrationRegistry, cap: ControllerCap) { + assert!(self.migrated_identities.contains(cap.controller_of()), EIdentityNotMigrated); + cap.delete_controller_cap(); +} + +public(package) fun insert(self: &mut AAMigrationRegistry, old_id: ID, new_id: ID) { + self.migrated_identities.add(old_id, new_id); +} diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move new file mode 100644 index 000000000..48566ef0b --- /dev/null +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move @@ -0,0 +1,113 @@ +#[test_only] +module iota_identity::aa_migration_tests; + +use iota::authenticator_function::{Self, AuthenticatorFunctionRefV1}; +use iota::clock::{Self, Clock}; +use iota::test_scenario; +use iota::vec_map; +use iota_identity::aa_migration_registry::{Self, AAMigrationRegistry}; +use iota_identity::controller::ControllerCap; +use iota_identity::identity::{Self, Identity}; +use iota_identity::identity_v2::IdentityV2; +use std::ascii; + +const CONTROLLER_A: address = @0x1; +const CONTROLLER_B: address = @0x2; +const CONTROLLER_C: address = @0x3; + +// Creates an identity with 3 controllers threshold is 2: +// - Controller A: address 0x1, weight 1; +// - Controller B: address 0x2, weight 1; +// - Controller C: address 0x3, weight 2; +fun make_identity(clock: &Clock, ctx: &mut TxContext) { + identity::new_with_controllers( + option::some(b"DID"), + vec_map::from_keys_values( + vector[CONTROLLER_A, CONTROLLER_B, CONTROLLER_C], + vector[1, 1, 2], + ), + vec_map::empty(), + 2, + clock, + ctx, + ); +} + +fun auth_fn_ref(): AuthenticatorFunctionRefV1 { + authenticator_function::create_auth_function_ref_v1_for_testing( + @0x0, + ascii::string(b"identity_v2"), + ascii::string(b"authenticate_v1"), + ) +} + +#[test] +fun migrate_identity_with_multiple_controllers() { + let mut scenario = test_scenario::begin(@identity_team); + let clock = clock::create_for_testing(scenario.ctx()); + aa_migration_registry::new(scenario.ctx()); + + scenario.next_tx(CONTROLLER_A); + let mut migration_registry = scenario.take_shared(); + + make_identity(&clock, scenario.ctx()); + scenario.next_tx(CONTROLLER_A); + + let mut identity = scenario.take_shared(); + let legacy_id = identity.id().to_inner(); + let mut controller_a_cap = scenario.take_from_sender(); + + identity.propose_or_approve_aa_migration(&mut controller_a_cap, scenario.ctx()); + scenario.next_tx(CONTROLLER_B); + + let controller_b_cap = scenario.take_from_sender(); + identity.execute_aa_migration( + controller_b_cap, + &mut migration_registry, + auth_fn_ref(), + scenario.ctx(), + ); + scenario.next_tx(CONTROLLER_C); + + let controller_c_cap = scenario.take_from_sender(); + migration_registry.delete_controller_cap(controller_c_cap); + scenario.next_tx(CONTROLLER_A); + + migration_registry.delete_controller_cap(controller_a_cap); + let identity_v2 = scenario.take_shared(); + let config = identity_v2.borrow_config(); + + assert!(config.borrow_controller(CONTROLLER_A).weight() == 1, 0); + assert!(config.borrow_controller(CONTROLLER_B).weight() == 1, 0); + assert!(config.controllers().length() == 2, 0); + assert!(identity_v2.legacy_id() == option::some(legacy_id), 0); + + test_scenario::return_shared(identity_v2); + test_scenario::return_shared(migration_registry); + clock.destroy_for_testing(); + + scenario.end(); +} + +#[test, expected_failure(abort_code = identity::EThresholdNotReached)] +fun migrate_requires_enough_votes() { + let mut scenario = test_scenario::begin(@identity_team); + let clock = clock::create_for_testing(scenario.ctx()); + aa_migration_registry::new(scenario.ctx()); + + scenario.next_tx(CONTROLLER_A); + let mut migration_registry = scenario.take_shared(); + + make_identity(&clock, scenario.ctx()); + scenario.next_tx(CONTROLLER_A); + + let identity = scenario.take_shared(); + let controller_a_cap = scenario.take_from_sender(); + + identity.execute_aa_migration(controller_a_cap, &mut migration_registry, auth_fn_ref(), scenario.ctx()); + + scenario.end(); + + clock.destroy_for_testing(); + test_scenario::return_shared(migration_registry); +} \ No newline at end of file diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move new file mode 100644 index 000000000..534461cf0 --- /dev/null +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move @@ -0,0 +1,59 @@ +module iota_identity::transaction; + +use iota::table::Table; + +#[error(code = 0)] +const EAlreadyApproved: vector = b"Transaction already approved by this approver"; +#[error(code = 1)] +const ENotAnApprover: vector = b"Address is not an approver for this transaction"; + +public struct Transaction has store, drop { + digest: vector, + approvers: vector
, +} + +public fun digest(self: &Transaction): vector { + self.digest +} + +public fun approvers(self: &Transaction): vector
{ + self.approvers +} + +public fun add_approver(self: &mut Transaction, approver: address) { + assert!(!self.approvers.contains(&approver), EAlreadyApproved); + self.approvers.push_back(approver); +} + +public fun remove_approver(self: &mut Transaction, approver: address) { + let idx = self.approvers.find_index!(|addr| *addr == approver); + assert!(idx.is_some(), ENotAnApprover); + + self.approvers.swap_remove(idx.destroy_some()); +} + +public struct Transactions has store { + transactions: Table, Transaction>, +} + +public fun new(ctx: &mut TxContext): Transactions { + Transactions { + transactions: iota::table::new(ctx), + } +} + +public fun contains(self: &Transactions, digest: &vector): bool { + self.transactions.contains(*digest) +} + +public fun borrow(self: &Transactions, digest: &vector): &Transaction { + self.transactions.borrow(*digest) +} + +public fun borrow_mut(self: &mut Transactions, digest: &vector): &mut Transaction { + self.transactions.borrow_mut(*digest) +} + +public fun insert(self: &mut Transactions, digest: vector) { + self.transactions.add(digest, Transaction { digest, approvers: vector::empty() }); +} \ No newline at end of file diff --git a/identity_iota_core/packages/iota_identity/sources/multicontroller.move b/identity_iota_core/packages/iota_identity/sources/multicontroller.move index 246571d35..432a973ed 100644 --- a/identity_iota_core/packages/iota_identity/sources/multicontroller.move +++ b/identity_iota_core/packages/iota_identity/sources/multicontroller.move @@ -367,6 +367,10 @@ public fun destroy_delegation_token(self: &mut Multicontroller, token: Del public fun delete(self: Multicontroller): V { assert!(self.active_proposals.is_empty(), ECannotDelete); + self.force_delete() +} + +public(package) fun force_delete(self: Multicontroller): V { let Multicontroller { controlled_value, proposals, diff --git a/identity_iota_core/packages/iota_identity/sources/proposals/migrate.move b/identity_iota_core/packages/iota_identity/sources/proposals/migrate.move new file mode 100644 index 000000000..81bb4c513 --- /dev/null +++ b/identity_iota_core/packages/iota_identity/sources/proposals/migrate.move @@ -0,0 +1,32 @@ +module iota_identity::aa_migration; + +public struct AAMigrationProposal has store, drop { + controllers: vector
, + weights: vector, +} + +public fun new(): AAMigrationProposal { + AAMigrationProposal { + controllers: vector::empty(), + weights: vector::empty(), + } +} + +public fun insert_controller(self: &mut AAMigrationProposal, addr: address, weight: u64) { + if (self.controllers.contains(&addr)) { + let (_, index) = self.controllers.index_of(&addr); + let curr_weight = self.weights[index]; + *self.weights.borrow_mut(index) = curr_weight.max(weight); + } else { + self.controllers.push_back(addr); + self.weights.push_back(weight); + } +} + +public fun controllers(self: &AAMigrationProposal): vector
{ + self.controllers +} + +public fun weights(self: &AAMigrationProposal): vector { + self.weights +} \ No newline at end of file diff --git a/identity_iota_core/packages/iota_identity/tests/Cargo.toml b/identity_iota_core/packages/iota_identity/tests/Cargo.toml new file mode 100644 index 000000000..785fe5e1a --- /dev/null +++ b/identity_iota_core/packages/iota_identity/tests/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "aa-enabled-identity" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.102" +iota-sdk = { git = "https://github.com/iotaledger/iota-rust-sdk.git" } +rand = "0.8.6" +serde = { version = "1.0.228", features = ["alloc", "derive"] } +serde_json = { version = "1.0.150", features = ["alloc"] } +tokio = { version = "1.52.3", features = ["full"] } + +[workspace] diff --git a/identity_iota_core/packages/iota_identity/tests/src/identity.rs b/identity_iota_core/packages/iota_identity/tests/src/identity.rs new file mode 100644 index 000000000..9c66c9bde --- /dev/null +++ b/identity_iota_core/packages/iota_identity/tests/src/identity.rs @@ -0,0 +1,301 @@ +use std::collections::HashSet; + +use super::{FromMoveViewCallResult, init}; +use anyhow::{Context as _, anyhow}; +use iota_sdk::{ + crypto::{Signer, ed25519::Ed25519PrivateKey}, + graphql_client::{Client, WaitForTx, query_types::MoveViewResult}, + transaction_builder::{MoveAuthenticatorBuilder, Shared, SharedMut, TransactionBuilder, TransactionSigner}, + types::{Address, Ed25519Signature, ObjectId, PublicKeyExt as _, Transaction, TransactionEffects, TypeTag}, +}; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub struct Identity { + pub id: ObjectId, + pub document_metadata: DidDocumentMetadata, + pub config: IdentityConfig, + pub legacy_id: Option, +} + +impl Identity { + pub async fn update_did_document( + &mut self, + did_document: &[u8], + sk: &Ed25519PrivateKey, + client: &Client, + ) -> anyhow::Result> { + let invoking_controller = self + .config + .controllers + .iter() + .find(|c| c.address == sk.public_key().derive_address()) + .context("not a controller")?; + let update_did_tx = self.prepare_update_did_document_tx(did_document, client).await?; + + if invoking_controller.weight >= self.config.threshold { + let effects = self.execute_tx(update_did_tx, sk, client).await?; + if effects.as_v1().status.is_success() { + *self = get_identity(client, self.id).await?; + } else { + anyhow::bail!("Failed to update DID: {:?}", effects.as_v1().status); + } + + Ok(TransactionProposalResult::Executed(())) + } else { + self.propose_tx(&update_did_tx, sk, client).await?; + Ok(TransactionProposalResult::Pending(update_did_tx)) + } + } + + async fn prepare_update_did_document_tx(&self, did_document: &[u8], client: &Client) -> anyhow::Result { + let config = init(); + let update_did_tx = { + let mut tx_builder = TransactionBuilder::new(*self.id.as_address()).with_client(client.clone()); + tx_builder + .move_call(config.identity_pkg_id, "identity_v2", "update_did_document") + .arguments(( + SharedMut(self.id), + did_document, + Shared(ObjectId::from_address(Address::CLOCK)), + )); + tx_builder.finish().await? + }; + + Ok(update_did_tx) + } + + pub async fn execute_tx( + &self, + tx: Transaction, + sk: &Ed25519PrivateKey, + client: &Client, + ) -> anyhow::Result { + let controller_sig: Ed25519Signature = Signer::sign(sk, tx.digest().as_bytes()); + let controller_pk = sk.public_key().to_flagged_bytes(); + let authenticator_params = MoveAuthenticatorBuilder::new(self.id) + .call_args((Some(controller_sig.as_bytes()), Some(controller_pk))) + .finish(&client) + .await?; + Ok( + TransactionBuilder::try_from(tx)? + .with_client(client.clone()) + .execute(&authenticator_params, WaitForTx::Finalized) + .await?, + ) + } + + async fn propose_tx(&self, tx: &Transaction, sk: &Ed25519PrivateKey, client: &Client) -> anyhow::Result<()> { + let config = init(); + let mut tx_builder = TransactionBuilder::new(sk.public_key().derive_address()).with_client(client.clone()); + tx_builder + .move_call(config.identity_pkg_id, "identity_v2", "propose_tx") + .arguments((SharedMut(self.id), tx.digest())); + let effects = tx_builder.execute(sk, WaitForTx::Finalized).await?; + + if effects.as_v1().status.is_success() { + Ok(()) + } else { + anyhow::bail!("Failed to update DID: {:?}", effects.as_v1().status); + } + } +} + +pub async fn get_identity(client: &Client, id: ObjectId) -> anyhow::Result { + let config = init(); + let document_metadata = make_move_view_call(client.move_view_call( + format!("{}::identity_v2::did_document", config.identity_pkg_id), + None, + [&id], + )) + .await?; + + let config = make_move_view_call(client.move_view_call( + format!("{}::identity_v2::borrow_config", config.identity_pkg_id), + None, + [&id], + )) + .await?; + + Ok(Identity { + id, + document_metadata, + config, + legacy_id: None, + }) +} + +pub async fn create_identity( + sender_address: Address, + did_document: &[u8], + controllers: &[Controller], + threshold: u64, + signer: &impl TransactionSigner, + client: &Client, +) -> anyhow::Result { + let config = init(); + let mut tx_builder = TransactionBuilder::new(sender_address).with_client(client.clone()); + let auth_fn = tx_builder + .move_call( + Address::FRAMEWORK, + "authenticator_function", + "create_auth_function_ref_v1", + ) + .arguments((config.package_metadata_id, "identity_v2", "authenticate_v1")) + .type_tags([format!("{}::identity_v2::IdentityV2", config.identity_pkg_id).parse::()?]) + .arg(); + let addresses = controllers.iter().map(|c| c.address).collect::>(); + let weights = controllers.iter().map(|c| c.weight).collect::>(); + let permissions = controllers.iter().map(|c| c.permissions).collect::>(); + + tx_builder + .move_call(config.identity_pkg_id, "identity_v2", "new_with_config") + .arguments(( + did_document, + addresses, + weights, + permissions, + threshold, + auth_fn, + Shared(ObjectId::from_address(Address::CLOCK)), + )); + let effects = tx_builder.execute(signer, WaitForTx::Finalized).await?; + + if effects.as_v1().status.is_failure() { + anyhow::bail!("Failed to create identity: {:?}", effects.as_v1().status); + } + + let identity_id = effects + .as_v1() + .changed_objects + .iter() + .find(|obj| { + obj.id_operation.is_created() + && obj + .output_state + .object_owner_opt() + .is_some_and(|owner| owner.is_shared()) + }) + .map(|obj| obj.object_id) + .unwrap(); + + get_identity(client, identity_id).await +} + +#[derive(Debug, Clone)] +pub struct DidDocumentMetadata { + pub document: Vec, + pub created: u64, + pub updated: u64, + pub deleted: bool, +} + +impl FromMoveViewCallResult for DidDocumentMetadata { + fn from_move_view_call_result(result: &mut Value) -> anyhow::Result { + let document = serde_json::from_value(result.get_mut("document").context("missing 'document' field")?.take())?; + let created = result + .get("created") + .and_then(|v| v.as_str()) + .context("missing 'created' field")? + .parse()?; + let updated = result + .get("updated") + .and_then(|v| v.as_str()) + .context("missing 'updated' field")? + .parse()?; + let deleted = result + .get("deleted") + .and_then(|v| v.as_bool()) + .context("missing 'deleted' field")?; + + Ok(Self { + document, + created, + updated, + deleted, + }) + } +} + +#[derive(Debug, Clone)] +pub struct IdentityConfig { + pub controllers: HashSet, + pub threshold: u64, +} + +impl FromMoveViewCallResult for IdentityConfig { + fn from_move_view_call_result(result: &mut Value) -> anyhow::Result { + let controllers = result + .get_mut("controllers") + .context("missing 'controllers' field")? + .as_array_mut() + .context("'controllers' field is not an array")? + .iter_mut() + .map(|obj| obj.get_mut("fields").unwrap()) + .map(Controller::from_move_view_call_result) + .collect::>>()?; + + let threshold = result + .get("threshold") + .and_then(|v| v.as_str()) + .context("missing 'threshold' field")? + .parse()?; + + Ok(Self { + controllers: controllers.into_iter().collect(), + threshold, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Controller { + pub address: Address, + pub weight: u64, + pub permissions: u64, +} + +impl FromMoveViewCallResult for Controller { + fn from_move_view_call_result(value: &mut Value) -> anyhow::Result { + let address = value + .get("addr") + .and_then(|v| v.as_str()) + .context("missing 'addr' field")? + .parse()?; + let weight = value + .get("weight") + .and_then(|v| v.as_str()) + .context("missing 'weight' field")? + .parse()?; + let permissions = value + .get("permissions") + .and_then(|v| v.as_str()) + .context("missing 'permissions' field")? + .parse()?; + + Ok(Self { + address, + weight, + permissions, + }) + } +} + +async fn make_move_view_call(view_call: F) -> anyhow::Result +where + F: Future>, + T: FromMoveViewCallResult, +{ + let res = view_call.await?; + let Some(mut results) = res.results else { + return Err(anyhow!(res.error.unwrap()).context("move view call failed")); + }; + let json_value = results.first_mut().unwrap().get_mut("fields").unwrap(); + T::from_move_view_call_result(json_value) +} + +#[derive(Debug)] +pub enum TransactionProposalResult { + Pending(Transaction), + Executed(T), +} diff --git a/identity_iota_core/packages/iota_identity/tests/src/lib.rs b/identity_iota_core/packages/iota_identity/tests/src/lib.rs new file mode 100644 index 000000000..9af62f2de --- /dev/null +++ b/identity_iota_core/packages/iota_identity/tests/src/lib.rs @@ -0,0 +1,33 @@ +use iota_sdk::types::ObjectId; +use std::sync::LazyLock; + +static CONFIG: LazyLock = LazyLock::new(|| { + let identity_pkg_id = std::env::var("IOTA_IDENTITY_PKG_ID") + .expect("IOTA_IDENTITY_PKG_ID environment variable not set") + .parse() + .expect("IOTA_IDENTITY_PKG_ID environment variable is not a valid ObjectId"); + let package_metadata_id = std::env::var("PKG_METADATA_ID") + .expect("PKG_METADATA_ID environment variable not set") + .parse() + .expect("PKG_METADATA_ID environment variable is not a valid ObjectId"); + Config { + identity_pkg_id, + package_metadata_id, + } +}); + +#[derive(Debug)] +pub struct Config { + pub identity_pkg_id: ObjectId, + pub package_metadata_id: ObjectId, +} + +pub mod identity; + +pub fn init() -> &'static Config { + &CONFIG +} + +trait FromMoveViewCallResult: Sized { + fn from_move_view_call_result(result: &mut serde_json::Value) -> anyhow::Result; +} diff --git a/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs b/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs new file mode 100644 index 000000000..9cff8f355 --- /dev/null +++ b/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs @@ -0,0 +1,146 @@ +use aa_enabled_identity::identity::{Controller, TransactionProposalResult, create_identity, get_identity}; +use iota_sdk::{ + crypto::ed25519::Ed25519PrivateKey, + graphql_client::{Client, faucet::FaucetClient}, +}; + +#[tokio::test] +async fn simple_update_did() -> anyhow::Result<()> { + let sk = Ed25519PrivateKey::generate(rand::thread_rng()); + let pk = sk.public_key(); + + let client = Client::new_localnet(); + // Fund the sender account. + let faucet_client = FaucetClient::new_localnet(); + faucet_client + .request_and_wait_for_finalized(pk.derive_address(), &client) + .await?; + + let mut identity = create_identity( + pk.derive_address(), + b"DID".as_slice(), + &[Controller { + address: pk.derive_address(), + weight: 1, + permissions: u64::MAX, + }], + 1, + &sk, + &client, + ) + .await?; + + // Fund the identity with some tokens to be able to pay for transactions. + faucet_client + .request_and_wait_for_finalized(*identity.id.as_address(), &client) + .await?; + + identity + .update_did_document(b"new_did_document".as_slice(), &sk, &client) + .await?; + assert_eq!( + identity.document_metadata.document.as_slice(), + b"new_did_document".as_slice() + ); + + Ok(()) +} + +#[tokio::test] +async fn update_did_multiple_controllers() -> anyhow::Result<()> { + let controller_a_sk = Ed25519PrivateKey::generate(rand::thread_rng()); + let controller_a = controller_a_sk.public_key().derive_address(); + let controller_b_sk = Ed25519PrivateKey::generate(rand::thread_rng()); + let controller_b = controller_b_sk.public_key().derive_address(); + + let client = Client::new_localnet(); + // Fund the sender account. + let faucet_client = FaucetClient::new_localnet(); + faucet_client + .request_and_wait_for_finalized(controller_a_sk.public_key().derive_address(), &client) + .await?; + + let mut identity = create_identity( + controller_a, + b"DID".as_slice(), + &[ + Controller { + address: controller_a, + weight: 1, + permissions: u64::MAX, + }, + Controller { + address: controller_b, + weight: 1, + permissions: u64::MAX, + }, + ], + 2, + &controller_a_sk, + &client, + ) + .await?; + + faucet_client + .request_and_wait_for_finalized(*identity.id.as_address(), &client) + .await?; + + let TransactionProposalResult::Pending(tx) = identity + .update_did_document(b"new_did_document", &controller_a_sk, &client) + .await? + else { + unreachable!("controller_a alone cannot execute a tx directly"); + }; + + let effects = identity.execute_tx(tx, &controller_b_sk, &client).await?; + assert!(effects.as_v1().status.is_success()); + + identity = get_identity(&client, identity.id).await?; + assert_eq!( + identity.document_metadata.document.as_slice(), + b"new_did_document".as_slice() + ); + + Ok(()) +} + +#[tokio::test] +async fn cannot_update_did_without_permission_to_do_so() -> anyhow::Result<()> { + let sk = Ed25519PrivateKey::generate(rand::thread_rng()); + let pk = sk.public_key(); + + let client = Client::new_localnet(); + // Fund the sender account. + let faucet_client = FaucetClient::new_localnet(); + faucet_client + .request_and_wait_for_finalized(pk.derive_address(), &client) + .await?; + + let mut identity = create_identity( + pk.derive_address(), + b"DID".as_slice(), + &[Controller { + address: pk.derive_address(), + weight: 1, + permissions: u64::MAX & !(1 << 63 | 1 << 3), // All permissions but ADMIN and CAN_UPDATE_DID. + }], + 1, + &sk, + &client, + ) + .await?; + + // Fund the identity with some tokens to be able to pay for transactions. + faucet_client + .request_and_wait_for_finalized(*identity.id.as_address(), &client) + .await?; + + let err = identity + .update_did_document(b"new_did_document".as_slice(), &sk, &client) + .await + .unwrap_err(); + + assert!(format!("{err:?}").contains("assert_permissions")); + + Ok(()) +} From 1f9aedba5070c34f81f7f9b64e2c150780abb7ed Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Thu, 11 Jun 2026 15:09:06 +0200 Subject: [PATCH 2/5] ensure tx proposal is removed once it's executed --- .../sources/identity_v2/identity_v2.move | 61 +++++++++++++++++-- .../sources/identity_v2/transaction.move | 4 ++ .../iota_identity/tests/src/identity.rs | 15 +++-- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move index e4da538a1..a8f68d11a 100644 --- a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move @@ -36,10 +36,13 @@ const ETransactionDigestMismatch: vector = b"Transaction digest mismatch"; #[error(code = 7)] const EInsufficientPermissions: vector = b"Controller does not have sufficient permissions"; #[error(code = 8)] -const EInvalidParameter: vector = b"An invalid parameter was provided to the function"; -#[error(code = 9)] const ENotASubIdentity: vector = b"The provided identity is not a sub-identity of this identity"; +#[error(code = 9)] +const ELastCommandNotProposalRemoval: vector = b"The last command is not a call to remove_tx"; +#[error(code = 10)] +const EMissingTxReceiptRemoval: vector = + b"A TxExecutionReceipt was used for authentication but it was not removed"; public struct IdentityV2 has key { id: UID, @@ -144,6 +147,15 @@ public fun approve_tx(self: &mut IdentityV2, tx_digest: vector, ctx: &mut Tx tx.add_approver(ctx.sender()); } +public fun remove_tx(self: &mut IdentityV2, ctx: &mut TxContext) { + // This ensures that only the identity itself can update its DID Document, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + + let transactions: &mut Transactions = df::borrow_mut(&mut self.id, TransactionsKey {}); + transactions.remove(*ctx.digest()); +} + public fun id(self: &IdentityV2): ID { self.id.to_inner() } @@ -283,9 +295,9 @@ public fun add_tx_execution_receipt( tx_digest: vector, ctx: &mut TxContext, ) { - // This ensures that only the sub-identity itself can add a transaction execution receipt, + // This ensures that only the identity itself can add a transaction execution receipt, // hence an authenticator function must have been called successfully before this function is executed. - assert!(ctx.sender() == sub_identity.account_address(), ESenderNotIdentity); + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); let sub_identity_config: &IdentityConfig = df::borrow(&sub_identity.id, ConfigKey {}); assert!(sub_identity_config.contains(self.account_address()), ENotASubIdentity); @@ -297,6 +309,17 @@ public fun add_tx_execution_receipt( receipts_table.add(tx_digest, self.account_address()); } +public fun remove_tx_execution_receipt(self: &mut IdentityV2, ctx: &mut TxContext) { + // This ensures that only the sub-identity itself can add a transaction execution receipt, + // hence an authenticator function must have been called successfully before this function is executed. + assert!(ctx.sender() == self.account_address(), ESenderNotIdentity); + let receipts_table: &mut Table, address> = df::borrow_mut( + &mut self.id, + TxExecutionReceiptsKey {}, + ); + receipts_table.remove(*ctx.digest()); +} + public fun legacy_id(self: &IdentityV2): Option { if (df::exists_with_type<_, ID>(&self.id, LegacyIdKey {})) { option::some(*df::borrow(&self.id, LegacyIdKey {})) @@ -366,12 +389,14 @@ public fun authenticate_v1( assert!(ctx.sender() == identity.account_address(), ESenderNotIdentity); let config: &IdentityConfig = df::borrow(&identity.id, ConfigKey {}); let transactions: &Transactions = df::borrow(&identity.id, TransactionsKey {}); + let mut has_authenticated_through_receipt = false; // Extract the invoking controller from the provided authentication parameters and validate the authenticity of the invocation. let controller = if (controller_sig.is_some() && controller_pk.is_some()) { let controller_sig = controller_sig.destroy_some(); let controller_pk = controller_pk.destroy_some(); validate_controller_signature(&controller_pk, &controller_sig, config, ctx.digest()) } else { + has_authenticated_through_receipt = true; let receipt_table = df::borrow(&identity.id, TxExecutionReceiptsKey {}); check_for_receipt(receipt_table, config, ctx.digest()) }; @@ -382,7 +407,8 @@ public fun authenticate_v1( let mut largest_weight = controller.weight(); let mut approvals = controller.weight(); let mut comulative_permissions = controller.permissions(); - if (transactions.contains(ctx.digest())) { + let has_proposed_tx = transactions.contains(ctx.digest()); + if (has_proposed_tx) { let tx = transactions.borrow(ctx.digest()); tx.approvers().do_ref!(|addr| { if (addr != controller.addr()) { @@ -405,6 +431,27 @@ public fun authenticate_v1( comulative_permissions, largest_weight, ); + + if (has_proposed_tx) { + ensure_proposal_removal(auth_ctx.tx_commands()); + }; + if (has_authenticated_through_receipt) { + ensure_receipt_removal(auth_ctx.tx_commands()); + }; +} + +fun ensure_proposal_removal(commands: &vector) { + let cmd = commands.borrow(commands.length() - 1); + let move_call = cmd.as_move_call().destroy_some(); + + assert!(move_call_is(&move_call, b"remove_tx"), ELastCommandNotProposalRemoval); +} + +fun ensure_receipt_removal(commands: &vector) { + let cmd = commands.borrow(commands.length() - 2); + let move_call = cmd.as_move_call().destroy_some(); + + assert!(move_call_is(&move_call, b"remove_tx_execution_receipt"), EMissingTxReceiptRemoval); } fun validate_auth_fn(auth_fn: &AuthenticatorFunctionRefV1) { @@ -557,7 +604,9 @@ fun validate_remove_controller_call( } fun move_call_is(cmd: &ProgrammableMoveCall, function: vector): bool { - cmd.module_name().as_bytes() == b"identity_v2" && cmd.function().as_bytes() == function + *cmd.package() == identity_v2_pkg_id() + && cmd.module_name().as_bytes() == b"identity_v2" + && cmd.function().as_bytes() == function } fun assert_permissions(permissions: u64, required_permissions: u64) { diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move index 534461cf0..a2074996d 100644 --- a/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/transaction.move @@ -56,4 +56,8 @@ public fun borrow_mut(self: &mut Transactions, digest: &vector): &mut Transa public fun insert(self: &mut Transactions, digest: vector) { self.transactions.add(digest, Transaction { digest, approvers: vector::empty() }); +} + +public fun remove(self: &mut Transactions, digest: vector) { + self.transactions.remove(digest); } \ No newline at end of file diff --git a/identity_iota_core/packages/iota_identity/tests/src/identity.rs b/identity_iota_core/packages/iota_identity/tests/src/identity.rs index 9c66c9bde..d28696643 100644 --- a/identity_iota_core/packages/iota_identity/tests/src/identity.rs +++ b/identity_iota_core/packages/iota_identity/tests/src/identity.rs @@ -43,8 +43,8 @@ impl Identity { Ok(TransactionProposalResult::Executed(())) } else { - self.propose_tx(&update_did_tx, sk, client).await?; - Ok(TransactionProposalResult::Pending(update_did_tx)) + let pending_tx = self.propose_tx(update_did_tx, sk, client).await?; + Ok(TransactionProposalResult::Pending(pending_tx)) } } @@ -85,8 +85,15 @@ impl Identity { ) } - async fn propose_tx(&self, tx: &Transaction, sk: &Ed25519PrivateKey, client: &Client) -> anyhow::Result<()> { + async fn propose_tx(&self, tx: Transaction, sk: &Ed25519PrivateKey, client: &Client) -> anyhow::Result { let config = init(); + // Add a command for the removal of this proposal once it's executed. + let mut tx_builder = TransactionBuilder::try_from(tx)?.with_client(client.clone()); + tx_builder + .move_call(config.identity_pkg_id, "identity_v2", "remove_tx") + .arguments([SharedMut(self.id)]); + let tx = tx_builder.finish().await?; + let mut tx_builder = TransactionBuilder::new(sk.public_key().derive_address()).with_client(client.clone()); tx_builder .move_call(config.identity_pkg_id, "identity_v2", "propose_tx") @@ -94,7 +101,7 @@ impl Identity { let effects = tx_builder.execute(sk, WaitForTx::Finalized).await?; if effects.as_v1().status.is_success() { - Ok(()) + Ok(tx) } else { anyhow::bail!("Failed to update DID: {:?}", effects.as_v1().status); } From f50ca42de6326ae39949f4f402997d3bac5c27db Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Thu, 11 Jun 2026 16:51:06 +0200 Subject: [PATCH 3/5] ensure tx receipt removal and sub identity tx execution test --- .../sources/identity_v2/identity_v2.move | 16 +++- .../iota_identity/tests/src/identity.rs | 69 ++++++++++++++--- .../iota_identity/tests/tests/update_did.rs | 74 +++++++++++++++++++ 3 files changed, 145 insertions(+), 14 deletions(-) diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move index a8f68d11a..7e148027a 100644 --- a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move @@ -448,10 +448,20 @@ fun ensure_proposal_removal(commands: &vector) { } fun ensure_receipt_removal(commands: &vector) { - let cmd = commands.borrow(commands.length() - 2); - let move_call = cmd.as_move_call().destroy_some(); + let mut i = commands.length() - 2; + while (i < commands.length()) { + let cmd = commands.borrow(i); + if (cmd.is_move_call()) { + let move_call = cmd.as_move_call().destroy_some(); + if (move_call_is(&move_call, b"remove_tx_execution_receipt")) { + return + }; + }; + + i = i + 1; + }; - assert!(move_call_is(&move_call, b"remove_tx_execution_receipt"), EMissingTxReceiptRemoval); + abort EMissingTxReceiptRemoval; } fun validate_auth_fn(auth_fn: &AuthenticatorFunctionRefV1) { diff --git a/identity_iota_core/packages/iota_identity/tests/src/identity.rs b/identity_iota_core/packages/iota_identity/tests/src/identity.rs index d28696643..60531d356 100644 --- a/identity_iota_core/packages/iota_identity/tests/src/identity.rs +++ b/identity_iota_core/packages/iota_identity/tests/src/identity.rs @@ -25,12 +25,7 @@ impl Identity { sk: &Ed25519PrivateKey, client: &Client, ) -> anyhow::Result> { - let invoking_controller = self - .config - .controllers - .iter() - .find(|c| c.address == sk.public_key().derive_address()) - .context("not a controller")?; + let invoking_controller = self.invoking_controller(sk).context("not a controller")?; let update_did_tx = self.prepare_update_did_document_tx(did_document, client).await?; if invoking_controller.weight >= self.config.threshold { @@ -48,7 +43,11 @@ impl Identity { } } - async fn prepare_update_did_document_tx(&self, did_document: &[u8], client: &Client) -> anyhow::Result { + pub async fn prepare_update_did_document_tx( + &self, + did_document: &[u8], + client: &Client, + ) -> anyhow::Result { let config = init(); let update_did_tx = { let mut tx_builder = TransactionBuilder::new(*self.id.as_address()).with_client(client.clone()); @@ -68,15 +67,23 @@ impl Identity { pub async fn execute_tx( &self, tx: Transaction, - sk: &Ed25519PrivateKey, + sk: impl Into>, client: &Client, ) -> anyhow::Result { - let controller_sig: Ed25519Signature = Signer::sign(sk, tx.digest().as_bytes()); - let controller_pk = sk.public_key().to_flagged_bytes(); + let call_args = if let Some(sk) = sk.into() { + let controller_sig: Ed25519Signature = Signer::sign(sk, tx.digest().as_bytes()); + let controller_pk = sk.public_key().to_flagged_bytes(); + + (Some(controller_sig.as_bytes().to_vec()), Some(controller_pk)) + } else { + (None, None) + }; + let authenticator_params = MoveAuthenticatorBuilder::new(self.id) - .call_args((Some(controller_sig.as_bytes()), Some(controller_pk))) + .call_args(call_args) .finish(&client) .await?; + Ok( TransactionBuilder::try_from(tx)? .with_client(client.clone()) @@ -106,6 +113,46 @@ impl Identity { anyhow::bail!("Failed to update DID: {:?}", effects.as_v1().status); } } + + pub async fn propose_tx_to_sub_identity( + &self, + sub_identity: &Self, + tx: Transaction, + sk: &Ed25519PrivateKey, + client: &Client, + ) -> anyhow::Result<(TransactionProposalResult<()>, Transaction)> { + let config = init(); + let mut tx_builder = TransactionBuilder::try_from(tx)?.with_client(client.clone()); + tx_builder + .move_call(config.identity_pkg_id, "identity_v2", "remove_tx_execution_receipt") + .arguments([SharedMut(sub_identity.id)]); + let tx = tx_builder.finish().await?; + + let mut tx_builder = TransactionBuilder::new(*self.id.as_address()).with_client(client.clone()); + tx_builder + .move_call(config.identity_pkg_id, "identity_v2", "add_tx_execution_receipt") + .arguments((Shared(self.id), SharedMut(sub_identity.id), tx.digest())); + let make_receipt_tx = tx_builder.finish().await?; + let invoking_controller = self.invoking_controller(sk).context("not a controller")?; + if invoking_controller.weight >= self.config.threshold { + let effects = self.execute_tx(make_receipt_tx, sk, client).await?; + if effects.as_v1().status.is_success() { + Ok((TransactionProposalResult::Executed(()), tx)) + } else { + anyhow::bail!("Failed to create tx execution receipt: {:?}", effects.as_v1().status); + } + } else { + Ok((TransactionProposalResult::Pending(make_receipt_tx), tx)) + } + } + + fn invoking_controller(&self, sk: &Ed25519PrivateKey) -> Option<&Controller> { + self + .config + .controllers + .iter() + .find(|c| c.address == sk.public_key().derive_address()) + } } pub async fn get_identity(client: &Client, id: ObjectId) -> anyhow::Result { diff --git a/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs b/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs index 9cff8f355..0ca2a89bc 100644 --- a/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs +++ b/identity_iota_core/packages/iota_identity/tests/tests/update_did.rs @@ -144,3 +144,77 @@ async fn cannot_update_did_without_permission_to_do_so() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test] +async fn identity_can_update_sub_identity_did_doc() -> anyhow::Result<()> { + let sk = Ed25519PrivateKey::generate(rand::thread_rng()); + let pk = sk.public_key(); + + let client = Client::new_localnet(); + // Fund the sender account. + let faucet_client = FaucetClient::new_localnet(); + faucet_client + .request_and_wait_for_finalized(pk.derive_address(), &client) + .await?; + + // Create a first identity controller by the previously created address. + let identity = create_identity( + pk.derive_address(), + b"DID".as_slice(), + &[Controller { + address: pk.derive_address(), + weight: 1, + permissions: u64::MAX, + }], + 1, + &sk, + &client, + ) + .await?; + + // Fund the identity with some tokens to be able to pay for transactions. + faucet_client + .request_and_wait_for_finalized(*identity.id.as_address(), &client) + .await?; + + // Create another identity controller by the first one. + let sub_identity = create_identity( + pk.derive_address(), + b"DID".as_slice(), + &[Controller { + address: *identity.id.as_address(), + weight: 1, + permissions: u64::MAX, + }], + 1, + &sk, + &client, + ) + .await?; + + // Fund the identity with some tokens to be able to pay for transactions. + faucet_client + .request_and_wait_for_finalized(*sub_identity.id.as_address(), &client) + .await?; + + // Prepare a tx to update the sub_identity's did document and propose it (make a tx receipt for its execution). + let update_did_tx = sub_identity + .prepare_update_did_document_tx(b"new did doc".as_slice(), &client) + .await?; + let (TransactionProposalResult::Executed(_), tx) = identity + .propose_tx_to_sub_identity(&sub_identity, update_did_tx, &sk, &client) + .await? + else { + unreachable!("controller has enough voting power to execute tx alone"); + }; + + // Execute the transaction. Passing `None` there ensure a tx receipt is consumed during authentication. + let effects = sub_identity.execute_tx(tx, None, &client).await?; + assert!(effects.as_v1().status.is_success()); + + // Re-sync sub-identity. + let sub_identity = get_identity(&client, sub_identity.id).await?; + assert_eq!(&sub_identity.document_metadata.document, b"new did doc".as_slice()); + + Ok(()) +} From ad539e6fb2d602fa984b7ff637a1a9ee529d9578 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Fri, 12 Jun 2026 15:55:04 +0200 Subject: [PATCH 4/5] `borrow_parts_v1` Simplify the construction of an off-chain copy of an `IdentityV2` through a single `move_view_call` that returns its three components: - did document metadata; - configuration; - legacy ID (if any); --- .../sources/identity_v2/identity_v2.move | 11 +++++ .../iota_identity/tests/src/identity.rs | 43 +++++++------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move index 7e148027a..7e78868b4 100644 --- a/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/identity_v2.move @@ -178,6 +178,17 @@ public fun borrow_config(self: &IdentityV2): &IdentityConfig { df::borrow(&self.id, ConfigKey {}) } +/// Returns the parts that comprise this identity: +/// - DID Document and its metadata; +/// - Configuration (threshold, controllers metadata); +/// - Legacy ID (if the identity was the result of a migration); +public fun borrow_parts_v1(self: &IdentityV2): (&DidDocument, &IdentityConfig, Option) { + let did_document = df::borrow(&self.id, DidDocumentKey {}); + let config = df::borrow(&self.id, ConfigKey {}); + + (did_document, config, self.legacy_id()) +} + /// Updates the DID Document of this Identity. public fun update_did_document( self: &mut IdentityV2, diff --git a/identity_iota_core/packages/iota_identity/tests/src/identity.rs b/identity_iota_core/packages/iota_identity/tests/src/identity.rs index 60531d356..76146d52c 100644 --- a/identity_iota_core/packages/iota_identity/tests/src/identity.rs +++ b/identity_iota_core/packages/iota_identity/tests/src/identity.rs @@ -4,7 +4,7 @@ use super::{FromMoveViewCallResult, init}; use anyhow::{Context as _, anyhow}; use iota_sdk::{ crypto::{Signer, ed25519::Ed25519PrivateKey}, - graphql_client::{Client, WaitForTx, query_types::MoveViewResult}, + graphql_client::{Client, WaitForTx}, transaction_builder::{MoveAuthenticatorBuilder, Shared, SharedMut, TransactionBuilder, TransactionSigner}, types::{Address, Ed25519Signature, ObjectId, PublicKeyExt as _, Transaction, TransactionEffects, TypeTag}, }; @@ -157,19 +157,21 @@ impl Identity { pub async fn get_identity(client: &Client, id: ObjectId) -> anyhow::Result { let config = init(); - let document_metadata = make_move_view_call(client.move_view_call( - format!("{}::identity_v2::did_document", config.identity_pkg_id), - None, - [&id], - )) - .await?; - - let config = make_move_view_call(client.move_view_call( - format!("{}::identity_v2::borrow_config", config.identity_pkg_id), - None, - [&id], - )) - .await?; + let res = client + .move_view_call( + format!("{}::identity_v2::borrow_parts_v1", config.identity_pkg_id), + None, + [&id], + ) + .await?; + + let Some(mut results) = res.results else { + return Err(anyhow!(res.error.unwrap()).context("move view call failed")); + }; + + let document_metadata = + DidDocumentMetadata::from_move_view_call_result(results.first_mut().unwrap().get_mut("fields").unwrap())?; + let config = IdentityConfig::from_move_view_call_result(results.get_mut(1).unwrap().get_mut("fields").unwrap())?; Ok(Identity { id, @@ -335,19 +337,6 @@ impl FromMoveViewCallResult for Controller { } } -async fn make_move_view_call(view_call: F) -> anyhow::Result -where - F: Future>, - T: FromMoveViewCallResult, -{ - let res = view_call.await?; - let Some(mut results) = res.results else { - return Err(anyhow!(res.error.unwrap()).context("move view call failed")); - }; - let json_value = results.first_mut().unwrap().get_mut("fields").unwrap(); - T::from_move_view_call_result(json_value) -} - #[derive(Debug)] pub enum TransactionProposalResult { Pending(Transaction), From 8cd4e0d1c1a2186a1ccefb69f3f53d2ffbf59ca4 Mon Sep 17 00:00:00 2001 From: Enrico Marconi <31142849+UMR1352@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:48:00 +0200 Subject: [PATCH 5/5] Update identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move Co-authored-by: wulfraem --- .../iota_identity/sources/identity_v2/migration_tests.move | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move b/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move index 48566ef0b..357141e91 100644 --- a/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move +++ b/identity_iota_core/packages/iota_identity/sources/identity_v2/migration_tests.move @@ -19,7 +19,10 @@ const CONTROLLER_C: address = @0x3; // - Controller A: address 0x1, weight 1; // - Controller B: address 0x2, weight 1; // - Controller C: address 0x3, weight 2; -fun make_identity(clock: &Clock, ctx: &mut TxContext) { +/// Creates an identity with 3 controllers threshold is 2: +/// - Controller A: address 0x1, weight 1; +/// - Controller B: address 0x2, weight 1; +/// - Controller C: address 0x3, weight 2; identity::new_with_controllers( option::some(b"DID"), vec_map::from_keys_values(