Skip to content

Subnet sale offer#2745

Open
l0r1s wants to merge 14 commits into
devnet-readyfrom
subnet-sale-offer
Open

Subnet sale offer#2745
l0r1s wants to merge 14 commits into
devnet-readyfrom
subnet-sale-offer

Conversation

@l0r1s

@l0r1s l0r1s commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds subnet sale offers to the Subtensor pallet.

The goal is to let a subnet owner announce that a subnet is for sale, optionally
restricted to a specific buyer coldkey. Once an offer exists, a later crowdloan
lease flow can target that offer, pay the seller, and transform the subnet into a
lease atomically.

Creating an offer stores the sale terms by netuid and freezes the seller coldkey
and current subnet owner hotkey while the offer is active. This prevents the seller
from mutating the relevant accounts during the sale. The seller or root can cancel
the offer, and sale-offer state is also cleaned up if the subnet is removed.

Sale offer identity

Each offer is assigned a unique, monotonically increasing SaleOfferId. Offers are
keyed by netuid, so the id lets a consumer (such as the future crowdloan lease
flow) bind to the exact offer it agreed to: if the seller cancels and recreates the
offer with different terms, the recreated offer carries a new id and the stale
binding no longer matches, rather than being silently satisfied by an offer with
modified terms.

Freeze enforcement

A CheckSubnetSale dispatch extension rejects frozen sale accounts at dispatch time:

  • A frozen seller coldkey may only cancel its sale offer; every other call is rejected.
  • A frozen owner hotkey is fully locked and may submit no calls.
  • Root bypasses the guard, and non-signed origins pass through.

Because this is a dispatch extension (not a transaction extension), the check fires
at every call.dispatch(origin) site, including indirectly dispatched calls (e.g.
proxy) with the resolved signer, so the freeze cannot be bypassed by wrapping a call
in another dispatch layer.

To keep the freeze consistent, swap_hotkey also refuses to swap a hotkey that is
currently frozen by a sale offer, which would otherwise move the subnet owner hotkey
out from under the offer (the owner hotkey can be owned by a coldkey other than the
frozen seller, so the coldkey freeze alone does not cover this).

Same-account owner

When the subnet owner coldkey and owner hotkey are the same account, only the coldkey
is frozen. Freezing the same account in the hotkey map as well would block
cancellation (a coldkey action) and permanently lock the seller out of their own
offer.

Other changes

  • New storage: SubnetSaleOffers, SubnetSaleFrozenColdkeys,
    SubnetSaleFrozenHotkeys, and the NextSubnetSaleOfferId counter.
  • New create_sale_offer and cancel_sale_offer extrinsics, with the associated
    events and errors.
  • Benchmarks and weights for both extrinsics and for the CheckSubnetSale dispatch
    extension.
  • Unit tests covering offer creation/cancellation, key freezing, cleanup on subnet
    removal, the same-account case, the swap_hotkey interaction, and the
    dispatch-extension guard (including proxied and nested-proxy dispatch).

Follow-up

The buyer/finalize side is intentionally not included yet. When it lands,
finalization should re-verify that the subnet owner coldkey and hotkey still match
the values snapshotted in the offer (alongside the SaleOfferId) as a fail-safe
against any owner-identity drift during the sale.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/subtensor/src/guards/check_subnet_sale.rs Outdated
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

🛡️ AI Review — Skeptic (security review)

VERDICT: VULNERABLE

BASELINE scrutiny: l0r1s has write permission, an established account, substantial prior contribution history, and matching PR author/commit authors. Branch subnet-sale-offer -> devnet-ready.

No .github/ai-review/*, .github/copilot-instructions.md, Cargo.toml, Cargo.lock, or build-script changes are present in this diff. I found no malicious indicators, but the previously flagged sale-offer storage I/O undercharge is still present.

Findings

Sev File Finding
MEDIUM pallets/subtensor/src/weights.rs:2115 create_sale_offer undercharges storage I/O inline
MEDIUM pallets/subtensor/src/weights.rs:4639 Fallback create_sale_offer weight also undercharges storage I/O inline

Prior-comment reconciliation

  • 82c134bf: not addressed — The generated create_sale_offer weight still charges 7 reads and 3 writes even though NextSubnetSaleOfferId adds one read and one write.
  • 610bde28: not addressed — The fallback create_sale_offer weight still charges 7 reads and 3 writes with the same missing sale-offer id counter I/O.

Conclusion

The PR appears legitimate, but create_sale_offer still declares less DB weight than it actually consumes because the NextSubnetSaleOfferId counter read/write is omitted. That remains a runtime resource-accounting vulnerability until the generated and fallback weights are corrected.


📜 Previous run (superseded)
Sev File Finding Status
MEDIUM pallets/subtensor/src/weights.rs:2115 create_sale_offer undercharges storage I/O ➡️ Carried forward to current findings
The generated create_sale_offer weight still charges 7 reads and 3 writes even though NextSubnetSaleOfferId adds one read and one write.
MEDIUM pallets/subtensor/src/weights.rs:4639 Fallback create_sale_offer weight also undercharges storage I/O ➡️ Carried forward to current findings
The fallback create_sale_offer weight still charges 7 reads and 3 writes with the same missing sale-offer id counter I/O.

🔍 AI Review — Auditor (domain review)

VERDICT: 👎

Gittensor: LIKELY by repo-history heuristic; author has write access and substantial recent subtensor contribution history, so review focused on runtime correctness rather than intent.

PR body is substantive and matches the implementation at a high level. No duplicate-work recommendation from the overlapping PR list. I did not run tests; the blocking issues are static weight-accounting problems. I also could not auto-verify the devnet spec_version gate because DNS for dev.chain.opentensor.ai is unavailable in this sandbox, so I left runtime/src/lib.rs unchanged.

Findings

Sev File Finding
HIGH pallets/subtensor/src/guards/check_subnet_sale.rs:72 Benchmark the new dispatch extension weight inline
HIGH pallets/subtensor/src/weights.rs:2112 Regenerate weights for the new sale-offer extrinsics inline

Conclusion

Blocking on weight accounting: the new sale-offer extrinsics and dispatch guard need benchmarked/generated weights before this runtime change is safe to merge.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

return Err(Error::<T>::ColdkeyLockedDuringSale.into());
}

if is_sale_frozen_owner_hotkey && !is_mev_protected {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Seller cannot cancel when coldkey is also owner hotkey

Creating an offer freezes both the seller coldkey and the current owner hotkey. If those are the same account, the earlier coldkey branch allows cancel_sale_offer, but this owner-hotkey branch immediately rejects the same cancellation with HotkeyLockedDuringSale. The existing owner-hotkey setter only rejects system accounts, so this state is reachable and leaves the seller unable to cancel without root intervention. Let cancel_sale_offer pass this branch too; do_cancel_sale_offer still enforces that a signed caller must be the offer seller.

Suggested change
if is_sale_frozen_owner_hotkey && !is_mev_protected {
if is_sale_frozen_owner_hotkey && !is_sale_cancel && !is_mev_protected {

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

return Err(Error::<T>::ColdkeyLockedDuringSale.into());
}

if is_sale_frozen_owner_hotkey && !is_mev_protected {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Seller cannot cancel when coldkey is also owner hotkey

do_create_sale_offer allows seller == owner_hotkey, then freezes the same account in both maps. For that account, cancel_sale_offer passes the coldkey check but is rejected here as a frozen owner hotkey, leaving the seller unable to use the only non-root cancellation path while both keys remain frozen. Let sale cancellation pass this guard too; do_cancel_sale_offer already enforces that only the seller or root can actually cancel.

Suggested change
if is_sale_frozen_owner_hotkey && !is_mev_protected {
if is_sale_frozen_owner_hotkey && !is_sale_cancel && !is_mev_protected {

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +71 to +72
fn weight(_call: &CallOf<T>) -> Weight {
T::DbWeight::get().reads(2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Benchmark the new dispatch extension weight

CheckSubnetSale runs on every runtime dispatch and is also included in SubtensorTransactionExtension::weight, but it charges only T::DbWeight::get().reads(2). The existing subtensor dispatch guards use generated WeightInfo entries with measured ref_time and proof size; this new guard reads two account-keyed storage maps and needs the same treatment. Add a check_subnet_sale_extension benchmark beside the other guard benchmarks, regenerate weights.rs, and return that generated weight here.

Comment on lines +2111 to +2112
fn create_sale_offer() -> Weight {
Weight::from_parts(25_000_000, 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Regenerate weights for the new sale-offer extrinsics

The new create_sale_offer and cancel_sale_offer weights are handwritten constants with proof size 0, while the comments claim measured storage proofs. These extrinsics add/read multiple storage maps, including new unbounded maps, so they need generated benchmark output with measured ref_time and proof-size components. Under-accounted runtime weights can let blocks exceed intended execution/proof limits. Run the pallet benchmarks and replace both SubstrateWeight and () implementations with the generated values.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +2113 to +2114
.saturating_add(T::DbWeight::get().reads(7_u64))
.saturating_add(T::DbWeight::get().writes(3_u64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] create_sale_offer undercharges storage I/O

do_create_sale_offer calls get_next_sale_offer_id(), which reads and writes NextSubnetSaleOfferId, before inserting the sale offer and freeze maps. This weight accounts for only 7 reads and 3 writes, omitting that counter access. A user can therefore submit this extrinsic with less declared DB weight than it actually consumes. Regenerate the benchmark weights or account for the extra read/write here.

Suggested change
.saturating_add(T::DbWeight::get().reads(7_u64))
.saturating_add(T::DbWeight::get().writes(3_u64))
.saturating_add(T::DbWeight::get().reads(8_u64))
.saturating_add(T::DbWeight::get().writes(4_u64))

Comment on lines +4629 to +4630
.saturating_add(RocksDbWeight::get().reads(7_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Fallback create_sale_offer weight also undercharges storage I/O

The fallback WeightInfo for () implementation has the same undercount: it omits the NextSubnetSaleOfferId read/write performed by get_next_sale_offer_id(). Keep this in sync with the runtime weight so tests and fallback runtimes do not preserve the undercharge.

Suggested change
.saturating_add(RocksDbWeight::get().reads(7_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64))
.saturating_add(RocksDbWeight::get().reads(8_u64))
.saturating_add(RocksDbWeight::get().writes(4_u64))

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +2114 to +2115
.saturating_add(T::DbWeight::get().reads(7_u64))
.saturating_add(T::DbWeight::get().writes(3_u64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] create_sale_offer undercharges storage I/O

do_create_sale_offer now calls get_next_sale_offer_id(), which reads and writes NextSubnetSaleOfferId in addition to the seven reads and three sale-state writes documented here. The worst-case successful path performs 8 reads and 4 writes, so this generated weight still undercharges every successful sale-offer creation. Regenerate the benchmarked weights or at least include the missing counter I/O.

Suggested change
.saturating_add(T::DbWeight::get().reads(7_u64))
.saturating_add(T::DbWeight::get().writes(3_u64))
.saturating_add(T::DbWeight::get().reads(8_u64))
.saturating_add(T::DbWeight::get().writes(4_u64))

Comment on lines +4638 to +4639
.saturating_add(RocksDbWeight::get().reads(7_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Fallback create_sale_offer weight also undercharges storage I/O

The fallback WeightInfo for () has the same accounting gap: get_next_sale_offer_id() adds one NextSubnetSaleOfferId read and one write, so the fallback should charge 8 reads and 4 writes for the successful path.

Suggested change
.saturating_add(RocksDbWeight::get().reads(7_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64))
.saturating_add(RocksDbWeight::get().reads(8_u64))
.saturating_add(RocksDbWeight::get().writes(4_u64))

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +2114 to +2115
.saturating_add(T::DbWeight::get().reads(7_u64))
.saturating_add(T::DbWeight::get().writes(3_u64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] create_sale_offer undercharges storage I/O

do_create_sale_offer calls get_next_sale_offer_id(), which reads and writes NextSubnetSaleOfferId on every successful create path. This generated weight still charges only the seven existing reads and three writes, omitting the counter I/O, so the public extrinsic is undercharged. Charge 8 reads and 4 writes here and regenerate the benchmark metadata.

Suggested change
.saturating_add(T::DbWeight::get().reads(7_u64))
.saturating_add(T::DbWeight::get().writes(3_u64))
.saturating_add(T::DbWeight::get().reads(8_u64))
.saturating_add(T::DbWeight::get().writes(4_u64))

Comment on lines +4638 to +4639
.saturating_add(RocksDbWeight::get().reads(7_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Fallback create_sale_offer weight also undercharges storage I/O

The fallback WeightInfo for () implementation has the same missing NextSubnetSaleOfferId read/write as the generated implementation. Any runtime using this fallback will undercharge the same public extrinsic. Keep the fallback in sync at 8 reads and 4 writes.

Suggested change
.saturating_add(RocksDbWeight::get().reads(7_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64))
.saturating_add(RocksDbWeight::get().reads(8_u64))
.saturating_add(RocksDbWeight::get().writes(4_u64))

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant