Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions clients/tfchain-client-go/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,34 @@ type TwinAccountBounded struct {
Topics []types.Hash
}

// Twin transfer events
type TwinTransferRequested struct {
Phase types.Phase
RequestID types.U64 `json:"request_id"`
TwinID types.U32 `json:"twin_id"`
From AccountID `json:"from"`
To AccountID `json:"to"`
Topics []types.Hash
}

type TwinOwnershipTransferred struct {
Phase types.Phase
RequestID types.U64 `json:"request_id"`
TwinID types.U32 `json:"twin_id"`
From AccountID `json:"from"`
To AccountID `json:"to"`
Topics []types.Hash
}

type TwinTransferCanceled struct {
Phase types.Phase
RequestID types.U64 `json:"request_id"`
TwinID types.U32 `json:"twin_id"`
From AccountID `json:"from"`
To AccountID `json:"to"`
Topics []types.Hash
}

// numeric enum for unit
type Unit byte

Expand Down Expand Up @@ -414,12 +442,15 @@ type EventRecords struct {
TfgridModule_EntityDeleted []EntityDeleted //nolint:stylecheck,golint

// twin events
TfgridModule_TwinStored []TwinStored //nolint:stylecheck,golint
TfgridModule_TwinUpdated []TwinStored //nolint:stylecheck,golint
TfgridModule_TwinDeleted []TwinDeleted //nolint:stylecheck,golint
TfgridModule_TwinEntityStored []TwinEntityStored //nolint:stylecheck,golint
TfgridModule_TwinEntityRemoved []TwinEntityRemoved //nolint:stylecheck,golint
TfgridModule_TwinAccountBounded []TwinAccountBounded //nolint:stylecheck,golint
TfgridModule_TwinStored []TwinStored //nolint:stylecheck,golint
TfgridModule_TwinUpdated []TwinStored //nolint:stylecheck,golint
TfgridModule_TwinDeleted []TwinDeleted //nolint:stylecheck,golint
TfgridModule_TwinEntityStored []TwinEntityStored //nolint:stylecheck,golint
TfgridModule_TwinEntityRemoved []TwinEntityRemoved //nolint:stylecheck,golint
TfgridModule_TwinAccountBounded []TwinAccountBounded //nolint:stylecheck,golint
TfgridModule_TwinTransferRequested []TwinTransferRequested //nolint:stylecheck,golint
TfgridModule_TwinOwnershipTransferred []TwinOwnershipTransferred //nolint:stylecheck,golint
TfgridModule_TwinTransferCanceled []TwinTransferCanceled //nolint:stylecheck,golint

// policy events
TfgridModule_PricingPolicyStored []PricingPolicyStored //nolint:stylecheck,golint
Expand Down
81 changes: 81 additions & 0 deletions docs/architecture/0024-twin-ownership-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 24. Twin Ownership Transfer in pallet-tfgrid

Date: 2025-09-03

## Status

Accepted

## Context

Operators occasionally need to transfer ownership of an existing Twin to a different account (e.g., organization handover, compromised keys, or account restructuring).
Previously there was no safe, on-chain mediated process to move Twin ownership while preserving accounting invariants and preventing hijacks.

## Decision

Introduce a simple two-step transfer protocol implemented in `pallet-tfgrid` that:

- Requires the current (old) owner to initiate and specify the intended new account.
- Enforces that the new account has accepted Terms & Conditions and does not already own a Twin.
- Requires explicit acceptance by the new account.
- Allows the current owner to cancel a pending request at any time.
- Repatriates all reserved balance from old owner to new owner on acceptance.

### Dispatchables

- `request_twin_transfer(origin=old_account, new_account)`
- Origin is the current (old) owner.
- Validates preconditions and creates a pending transfer.
- Emits `TwinTransferRequested { request_id, twin_id, from, to }`.

- `accept_twin_transfer(origin=new_account, request_id)`
- Origin must be the intended new owner.
- Moves reserved balance from old to new as reserved, updates Twin owner and indexes, and completes the request.
- Emits `TwinOwnershipTransferred { request_id, twin_id, from, to }` and `TwinUpdated(Twin)`.

- `cancel_twin_transfer(origin=old_account, request_id)`
- Origin must be the current (old) owner.
- Cancels and removes the pending request.
- Emits `TwinTransferCanceled { request_id, twin_id, from, to }`.

### Storage

- `TwinTransferRequests: RequestId -> TwinTransferRequest` (twin_id, from, to, created_at)
- `PendingTransferByTwin: TwinId -> RequestId` (enforces one pending request per Twin)
- `TwinTransferRequestID: u64` (monotonic counter)

### Types

- `TwinTransferRequest` includes `from: AccountId`, `to: AccountId`, and `created_at: BlockNumber` to record when the request was created.
- There is no expiry logic in v1.
- Future cleaners (on_finalize/offchain) can use `created_at` to remove stale items if desired.

### Errors and Semantics

- Request flow is capped at one request per twin. If a request already exists, `request_twin_transfer` returns a single error:
- `TwinTransferPendingExists` ("cancel the existing request first")
- Accept flow has no expiry checks; presence of a matching request and correct signer are sufficient.
- Cancel flow always succeeds for the old owner:
- Removes the request and emits `TwinTransferCanceled`.

## Security Considerations

- Current owner cannot push a transfer without new account cooperation (accept step by new account is required).
- New account must have signed T&C and must not own another Twin (prevents multi-ownership and aligns with usage rules).
- Owner can cancel any time to unblock.
- On acceptance, reserved balance is repatriated using `repatriate_reserved(..., BalanceStatus::Reserved)`; failures are tolerated but the pallet attempts best-effort transfer before ownership move.

## Consequences

- Clear, auditable transfer trail via events.
- Compatible with existing Twin lifecycle; no changes to Twin schema.

## Backwards Compatibility & Migration

- New storage items are additive.
- No migration of existing state required.

## References

- Implementation: `substrate-node/pallets/pallet-tfgrid/src/twin_transfer.rs`
- Extrinsics wiring: `substrate-node/pallets/pallet-tfgrid/src/lib.rs` (call_index 40, 41, 42)
53 changes: 53 additions & 0 deletions docs/misc/twin_transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Twin Ownership Transfer

This document explains the twin ownership transfer flow in the `pallet-tfgrid` pallet and the main events and errors.

## Overview

- The current (old) owner initiates the transfer via `request_twin_transfer(new_account)`.
- The prospective new owner accepts via `accept_twin_transfer(request_id)`.
- The current owner may cancel via `cancel_twin_transfer(request_id)`.
- On success, ownership of the twin moves to the new account, reserved balances are repatriated, and indices are updated.

## Dispatchables

- request_twin_transfer(origin=old_owner, new_account)
- Origin (signer) is the current (old) owner; `twin_id` is derived from the signer.
- Emits `TwinTransferRequested { request_id, twin_id, from, to }`.
- accept_twin_transfer(origin=new_account, request_id)
- Origin must be the intended new owner of the twin.
- Emits `TwinOwnershipTransferred { request_id, twin_id, from, to }` and `TwinUpdated(Twin)`.
- cancel_twin_transfer(origin=old_owner, request_id)
- Origin must be the current (old) owner of the twin.
- Emits `TwinTransferCanceled { request_id, twin_id, from, to }`.

## Preconditions

- Twin must exist.
- New account must have accepted Terms & Conditions (`user_accept_tc`).
- New account must not already own a twin.
- Only one pending transfer per twin.

## Common Errors

- UserDidNotSignTermsAndConditions: new account did not accept T&C.
- TwinTransferNewAccountHasTwin: new account already has a twin.
- TwinTransferPendingExists: a pending transfer already exists for this twin.
- TwinTransferRequestNotFound: request ID does not exist.
- UnauthorizedToUpdateTwin: signer is not authorized for this action.

## Events

- TwinTransferRequested { request_id, twin_id, from, to }
- TwinOwnershipTransferred { request_id, twin_id, from, to }
- TwinTransferCanceled { request_id, twin_id, from, to }
- TwinUpdated(Twin)

## Storage

- `TwinTransferRequests: RequestId -> TwinTransferRequest` where `TwinTransferRequest { twin_id, from, to, created_at }`.
- `PendingTransferByTwin: TwinId -> RequestId` enforces at most one pending transfer per twin.

## Notes

- Reserved balance of the old owner is repatriated to the new owner as reserved during acceptance.
1 change: 1 addition & 0 deletions substrate-node/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions substrate-node/pallets/pallet-burning/src/weights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
//! Autogenerated weights for pallet_burning
//!
//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev
//! DATE: 2024-10-01, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
//! DATE: 2025-09-15, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
//! WORST CASE MAP SIZE: `1000000`
//! HOSTNAME: `585a9f003813`, CPU: `AMD Ryzen 7 5800X 8-Core Processor`
//! HOSTNAME: `ebea9d7a9918`, CPU: `AMD Ryzen 7 5800X 8-Core Processor`
//! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024

// Executed Command:
Expand Down Expand Up @@ -45,8 +45,8 @@ impl<T: frame_system::Config> WeightInfo for SubstrateWeight<T> {
// Proof Size summary in bytes:
// Measured: `109`
// Estimated: `1594`
// Minimum execution time: 27_051_000 picoseconds.
Weight::from_parts(27_762_000, 1594)
// Minimum execution time: 26_060_000 picoseconds.
Weight::from_parts(26_561_000, 1594)
.saturating_add(T::DbWeight::get().reads(1_u64))
.saturating_add(T::DbWeight::get().writes(1_u64))
}
Expand All @@ -60,8 +60,8 @@ impl WeightInfo for () {
// Proof Size summary in bytes:
// Measured: `109`
// Estimated: `1594`
// Minimum execution time: 27_051_000 picoseconds.
Weight::from_parts(27_762_000, 1594)
// Minimum execution time: 26_060_000 picoseconds.
Weight::from_parts(26_561_000, 1594)
.saturating_add(RocksDbWeight::get().reads(1_u64))
.saturating_add(RocksDbWeight::get().writes(1_u64))
}
Expand Down
2 changes: 2 additions & 0 deletions substrate-node/pallets/pallet-dao/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pallet-tfgrid.workspace = true
[dev-dependencies]
sp-core.workspace = true
env_logger = "*"
pallet-balances.workspace = true

[features]
default = ["std"]
Expand All @@ -49,6 +50,7 @@ std = [
"pallet-membership/std",
"pallet-collective/std",
"pallet-timestamp/std",
"pallet-balances/std",
"pallet-tfgrid/std",
"tfchain-support/std",
"scale-info/std",
Expand Down
33 changes: 30 additions & 3 deletions substrate-node/pallets/pallet-dao/src/mock.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use crate::{self as pallet_dao};
use frame_support::{construct_runtime, parameter_types, traits::ConstU32, BoundedVec};
use frame_system::EnsureRoot;
use pallet_collective;
use pallet_tfgrid::{
farm::FarmName,
interface::{InterfaceIp, InterfaceMac, InterfaceName},
Expand All @@ -10,7 +9,6 @@ use pallet_tfgrid::{
CityNameInput, CountryNameInput, DocumentHashInput, DocumentLinkInput, Gw4Input, Ip4Input,
LatitudeInput, LongitudeInput, PkInput, RelayInput,
};
use pallet_timestamp;
use sp_core::H256;
use sp_runtime::{
traits::{BlakeTwo256, IdentityLookup},
Expand All @@ -28,6 +26,7 @@ construct_runtime!(
{
System: frame_system::{Pallet, Call, Config<T>, Storage, Event<T>},
DaoModule: pallet_dao::pallet::{Pallet, Call, Storage, Event<T>},
Balances: pallet_balances::{Pallet, Call, Storage, Config<T>, Event<T>},
TfgridModule: pallet_tfgrid::{Pallet, Call, Storage, Event<T>},
Timestamp: pallet_timestamp::{Pallet, Call, Storage, Inherent},
Council: pallet_collective::<Instance1>::{Pallet, Call, Origin<T>, Event<T>, Config<T>},
Expand Down Expand Up @@ -58,13 +57,40 @@ impl frame_system::Config for TestRuntime {
type PalletInfo = PalletInfo;
type OnNewAccount = ();
type OnKilledAccount = ();
type AccountData = ();
type AccountData = pallet_balances::AccountData<u64>;
type SystemWeightInfo = ();
type SS58Prefix = ();
type OnSetCode = ();
type MaxConsumers = ConstU32<16>;
}

// Balances pallet configuration for the mock runtime
pub const EXISTENTIAL_DEPOSIT: u64 = 500;

parameter_types! {
pub const MaxLocks: u32 = 50;
pub const MaxReserves: u32 = 50;
pub const ExistentialDepositBalance: u64 = EXISTENTIAL_DEPOSIT;
}

impl pallet_balances::Config for TestRuntime {
type MaxLocks = MaxLocks;
type MaxReserves = MaxReserves;
type ReserveIdentifier = [u8; 8];
/// The type for recording an account's balance.
type Balance = u64;
/// The ubiquitous event type.
type RuntimeEvent = RuntimeEvent;
type DustRemoval = ();
type ExistentialDeposit = ExistentialDepositBalance;
type AccountStore = System;
type WeightInfo = pallet_balances::weights::SubstrateWeight<TestRuntime>;
type FreezeIdentifier = ();
type MaxFreezes = ();
type RuntimeHoldReason = ();
type MaxHolds = ();
}

pub type BlockNumber = u32;
parameter_types! {
pub const DaoMotionDuration: BlockNumber = 4;
Expand Down Expand Up @@ -156,6 +182,7 @@ impl pallet_tfgrid::Config for TestRuntime {
type Location = TestLocation;
type SerialNumber = TestSerialNumber;
type TimestampHintDrift = TimestampHintDrift;
type Currency = Balances;
}

impl pallet_timestamp::Config for TestRuntime {
Expand Down
Loading