agents/skills/sui/object-ownership/SKILL.md
Trigger Pattern Always required for Sui Move audits -- object lifecycle and ownership model - Inject Into Breadth agents, depth-state-trace, depth-token-flow
npx skillsauth add plamentsv/plamen object-ownershipInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Trigger Pattern: Always required for Sui Move audits -- object lifecycle and ownership model Inject Into: Breadth agents, depth-state-trace, depth-token-flow Finding prefix:
[OO-N]Rules referenced: R4, R5, R9, R10, R13
Sui's object-centric model is fundamentally different from account-based chains. Every struct with the key ability is an on-chain object with a globally unique ID, and its ownership model (owned/shared/frozen/wrapped) determines who can access and mutate it. Incorrect ownership choices, missing transfer restrictions, orphaned UIDs, and uncontrolled dynamic fields are the primary Sui-specific vulnerability classes.
For EVERY struct with key ability in the codebase, build this table:
| # | Object Name (Module) | Abilities | Ownership Model | Created Where | Transferred Where | Destroyed Where | Has store? |
|---|---------------------|-----------|-----------------|---------------|-------------------|-----------------|-------------|
| 1 | {name} ({module}) | {key, store, ...} | OWNED / SHARED / FROZEN / WRAPPED / MIXED | {function:line} | {function:line or NEVER} | {function:line or NEVER} | YES/NO |
Ability rules:
key alone: Object can exist on-chain but CANNOT be transferred by generic transfer::public_transfer (requires module-defined transfer logic).key + store: Object CAN be transferred by anyone via transfer::public_transfer. This is a permissive choice -- verify it is intentional.key + store + copy: Object can be duplicated -- extremely rare for value-bearing objects. FLAG if found on any object holding balances.key + store + drop: Object can be silently discarded without calling a destructor. FLAG if the object holds Balance<T> or other value -- tokens can be lost.Ownership model classification:
transfer::transfer or transfer::public_transfer. Only the owner can pass it as a transaction argument.transfer::public_share_object. Any transaction can read/write it. CRITICAL access control implications.transfer::public_freeze_object. Anyone can read, no one can mutate.For each OWNED object:
| Object | Should Be Shared Instead? | Ownership Transfer Possible? | Transfer Restriction Correct? | Assumption Risk |
|--------|--------------------------|-----------------------------|-----------------------------|----------------|
| {name} | YES/NO ({reason}) | YES (has store) / NO (no store) | {analysis} | {risk if ownership changes} |
Check patterns:
store ability, it can be transferred to anyone. Verify that transfer does not break invariants downstream.@0x0). The object is effectively inaccessible -- equivalent to locked funds if it holds value.For each SHARED object:
| Object | Mutation Functions | Access Guards | Concurrent Mutation Risk | Ordering Dependency |
|--------|-------------------|---------------|------------------------|-------------------|
| {name} | {list all functions that take &mut ref} | {what prevents unauthorized mutation} | YES/NO ({analysis}) | YES/NO ({analysis}) |
CRITICAL checks:
&mut SharedObj without verifying the caller has authority (e.g., checking a capability object), anyone can mutate it. This is the #1 Sui vulnerability pattern.public function that returns &mut internal state, calls borrow_mut, or exposes dynamic-field mutable access is externally callable by attacker packages. It must be public(package) or require a capability unless external mutation is explicitly safe.public only because another module in the same package needs them. Use public(package) for same-package helpers.For each FROZEN object:
| Object | Should Updates Be Possible? | Freezing Reversible? | Data Staleness Risk | |--------|-----------------------------|---------------------|-------------------| | {name} | YES/NO ({reason}) | NO (by design) | {risk if frozen data becomes stale} |
Check: If frozen object holds configuration that may need updating (fee rates, oracle addresses, admin keys), freezing is likely wrong -- should be shared with access control instead.
For each WRAPPED object:
| Parent Object | Wrapped Object | Unwrap Path Exists? | Dynamic Fields on Wrapped? | Destruction Safety | |--------------|---------------|--------------------|--------------------------|--------------------| | {parent} | {wrapped} | YES ({function}) / NO | YES/NO | {what happens to wrapped when parent destroyed} |
Check patterns:
drop ability or explicit destructor) without first unwrapping/extracting the inner object, the inner object's value is lost.For each transfer::transfer, transfer::public_transfer, transfer::share_object, transfer::public_share_object, transfer::freeze_object, transfer::public_freeze_object call:
| # | Transfer Call | Object Type | Initiator | store Required? | store Present? | Recipient Validation | Stranded Risk |
|---|-------------|------------|-----------|-------------------|-----------------|---------------------|---------------|
| 1 | {function:line} | {type} | {who calls} | YES (public_*) / NO (module-only) | YES/NO | {is recipient validated?} | {can object be sent to address that cannot use it?} |
Check patterns:
store ability gate: transfer::public_transfer requires store. transfer::transfer does not -- it is module-restricted. If an object should NOT be freely transferable by holders, it should NOT have store.For each shared object, build the mutation map:
| Shared Object | Function | Mutation Type | Guard | Re-entrancy Risk | Ordering Sensitivity | |--------------|----------|--------------|-------|-----------------|---------------------| | {obj} | {func} | FIELD_UPDATE / BALANCE_CHANGE / CHILD_ADD / CHILD_REMOVE | {capability check, address check, or NONE} | {can another function on same object be called mid-execution?} | {does outcome depend on call order?} |
Sui-specific re-entrancy note: Move's borrow checker prevents re-entrancy within a single module (you cannot pass &mut Obj to a function that also borrows &mut Obj). However, cross-module re-entrancy is possible if Object A's mutation calls a function in Module B that calls back to Module A with a different entry point that accesses a DIFFERENT shared object whose state is coupled with Object A.
Concurrent mutation checklist:
For each wrapping relationship (object stored as field in another object):
| Wrapper | Wrapped | Wrap Point | Unwrap Point | Dynamic Fields Before Wrap | UID Preserved on Unwrap? | |---------|---------|-----------|-------------|--------------------------|-------------------------| | {parent} | {child} | {function:line} | {function:line or NONE} | YES/NO | YES/NO/N/A |
Check patterns:
dynamic_field::add(child_uid, ...) is called before child is wrapped into parent, those dynamic fields become inaccessible. They still exist on-chain (consuming storage) but cannot be read or removed until the child is unwrapped.Balance<T>, verify that total balance is preserved across wrap/unwrap cycles. No balance should be created or destroyed during wrapping.Every call to object::new(ctx) creates a UID. Every UID must be either:
key ability (the object's id field), ORobject::delete(id)For each object::new(ctx) call:
| # | Creation Location | UID Stored In | Destruction Location | Lifecycle Complete? | Orphan Risk | |---|------------------|--------------|---------------------|--------------------|-----------| | 1 | {function:line} | {object field or VARIABLE} | {function:line or NONE} | YES/NO | {if NO: resource leak} |
Check patterns:
object::new(ctx) is called but the resulting UID is not stored in an object that gets transferred/shared/frozen, and not deleted, it is a resource leak. The UID exists on-chain consuming storage but is unreachable.object::delete. Move's type system should prevent this, but verify in any unsafe or native code paths.object::delete(id), ALL dynamic fields on that UID should be removed. Otherwise, the dynamic fields become permanently orphaned (the UID no longer exists to access them through). Apply Rule 9: orphaned dynamic fields holding value = stranded assets = minimum MEDIUM.For each dynamic_field::add, dynamic_field::remove, dynamic_object_field::add, dynamic_object_field::remove:
| # | Operation | Parent UID | Field Name/Type | Value Type | Access Control | Unbounded Growth? | Cleanup on Delete? | |---|-----------|-----------|----------------|-----------|---------------|-------------------|-------------------| | 1 | ADD | {parent:line} | {name type + value} | {type} | {who can add} | YES/NO | {is field removed before parent UID deleted?} |
Check patterns:
dynamic_field::add is called in a loop or user-facing function without a cap, the parent object's dynamic field set grows without limit. This increases gas costs for operations that iterate related state and can be used as a DoS vector.dynamic_field::borrow to return unexpected data if field names collide.(TypeTag, name_value). If two different code paths add fields with the same key type and value, they overwrite each other. Verify field name uniqueness across all add operations on the same UID.dynamic_field::borrow<Name, Value> will abort if the stored value type does not match Value. Verify all borrow calls use consistent type parameters with the corresponding add calls.dynamic_object_field::add stores objects (with key ability) that retain their own UID and are independently addressable. dynamic_field::add wraps values. Using the wrong variant can make objects inaccessible or create unexpected behavior.object::delete), ALL dynamic fields must be removed. Build a removal completeness table:| Parent Object | Destruction Function | Dynamic Fields Added | Dynamic Fields Removed Before Delete | Complete? | |--------------|---------------------|---------------------|--------------------------------------|----------| | {obj} | {func:line} | {list all add operations} | {list all remove operations in destructor} | YES/NO |
## Finding [OO-N]: Title
**Verdict**: CONFIRMED / PARTIAL / REFUTED / CONTESTED
**Step Execution**: checkmark1,2,3,4,5,6,7 | x(reasons) | ?(uncertain)
**Rules Applied**: [R4:Y/N, R5:Y/N, R9:Y/N, R10:Y/N, R13:Y/N]
**Severity**: Critical/High/Medium/Low/Info
**Location**: sources/{module}.move:LineN
**Description**: [Specific ownership/lifecycle issue with code reference]
**Impact**: [What can happen -- fund loss, state corruption, DoS, stranded assets]
### Precondition Analysis (if PARTIAL or REFUTED)
**Missing Precondition**: [What blocks this attack]
**Precondition Type**: STATE / ACCESS / TIMING / EXTERNAL / BALANCE
**Why This Blocks**: [Specific reason]
### Postcondition Analysis (if CONFIRMED or PARTIAL)
**Postconditions Created**: [What conditions this creates]
**Postcondition Types**: [STATE, ACCESS, TIMING, EXTERNAL, BALANCE]
**Who Benefits**: [Who can use these]
| Section | Required | Completed? | Notes |
|---------|----------|------------|-------|
| 1. Object Inventory | YES | Y/N/? | Every struct with key ability |
| 2a. Owned Object Audit | IF owned objects exist | Y/N(none)/? | Transfer restriction + assumption risk |
| 2b. Shared Object Audit | IF shared objects exist | Y/N(none)/? | Access control on mutation |
| 2c. Frozen Object Audit | IF frozen objects exist | Y/N(none)/? | Staleness risk |
| 2d. Wrapped Object Audit | IF wrapped objects exist | Y/N(none)/? | Unwrap path + value preservation |
| 3. Object Transfer Analysis | YES | Y/N/? | Every transfer/share/freeze call |
| 4. Shared Object Mutation Safety | IF shared objects mutated | Y/N(none)/? | Concurrent mutation + ordering |
| 5. Object Wrapping/Unwrapping | IF wrapping relationships exist | Y/N(none)/? | Dynamic field orphaning + UID preservation |
| 6. UID Lifecycle Audit | YES | Y/N/? | Every object::new matched to storage or delete |
| 7. Dynamic Field Audit | IF dynamic fields used | Y/N(none)/? | Growth bounds + cleanup completeness |
After Section 2b (Shared Object Audit): Feed unguarded mutation functions to SEMI_TRUSTED_ROLES skill if roles are involved in access control.
After Section 3 (Transfer Analysis): Feed objects with store ability to TOKEN_FLOW_TRACING skill for balance flow analysis.
After Section 6 (UID Lifecycle): Feed orphaned UIDs and incomplete dynamic field cleanup to depth-edge-case for stranded asset analysis (Rule 9).
After Section 7 (Dynamic Field Audit): Feed unbounded growth patterns to ECONOMIC_DESIGN_AUDIT for DoS cost analysis.
development
Prepare Solidity projects for a security audit — test coverage, test quality, NatSpec docs, code hygiene, dependency health, best-practice enforcement, deployment readiness, and project documentation checks. Generates a scored Audit Readiness Report and optionally runs static analysis. Trigger on: "prepare for audit", "audit readiness", "pre-audit check", "audit prep", "NatSpec check", or any request to review a Solidity codebase before a security review.
development
Launch the Plamen deterministic Web3 security audit pipeline
development
Run the Plamen smart-contract audit wizard in Codex
testing
Launch the Plamen deterministic L1 infrastructure audit pipeline