Skip to content

Commit a4b252b

Browse files
committed
fix: preserve intent safety through multisig dispatch
1 parent c1b8d0b commit a4b252b

7 files changed

Lines changed: 190 additions & 19 deletions

File tree

sdk/python/bittensor/cli/context.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ def submit(
530530
except ValueError as error:
531531
self.output.error(str(error))
532532
raise typer.Exit(2) from error
533+
semantic_intent = intent.semantic_intent()
533534

534535
# MEV shielding: explicit flag > persistent config > the intent's own
535536
# default. `forced` distinguishes "the user asked for shielding" (hard
@@ -538,10 +539,10 @@ def submit(
538539
# `mev_shield_required` intents (collateral AMM buys) refuse the
539540
# unshielded opt-out entirely.
540541
configured_shield = cfg.get("mev_shield")
541-
if intent.mev_shield_required:
542+
if semantic_intent.mev_shield_required:
542543
if self.mev_shield is False or configured_shield is False:
543544
self.output.error(
544-
f"{intent.op} must be submitted MEV-shielded",
545+
f"{semantic_intent.op} must be submitted MEV-shielded",
545546
help=(
546547
"collateral / burned-registration AMM fills cannot run "
547548
"unshielded; omit --no-mev-shield"
@@ -557,17 +558,17 @@ def submit(
557558
shield = bool(configured_shield)
558559
shield_forced = shield
559560
else:
560-
shield = intent.mev_shield_default
561+
shield = semantic_intent.mev_shield_default
561562
shield_forced = False
562563
if shield and (proxy_for is not None or self.uses_extension_signer()):
563564
blocker = "a proxied call" if proxy_for is not None else "the extension signer"
564-
if shield_forced or intent.mev_shield_required:
565+
if shield_forced or semantic_intent.mev_shield_required:
565566
self.output.error(
566567
f"MEV shielding cannot wrap {blocker}",
567568
help=(
568569
"collateral intents cannot fall back to unshielded; "
569570
"sign directly without a proxy/extension"
570-
if intent.mev_shield_required
571+
if semantic_intent.mev_shield_required
571572
else "pass --no-mev-shield to submit unshielded"
572573
),
573574
)
@@ -607,12 +608,12 @@ async def _shield_fee_preflight(client):
607608

608609
shortfall = self.run(_shield_fee_preflight)
609610
if shortfall is not None:
610-
if shield_forced or intent.mev_shield_required:
611+
if shield_forced or semantic_intent.mev_shield_required:
611612
self.output.error(
612613
"MEV-shielded submission needs free TAO for the outer carrier fee",
613614
help=(
614-
f"{intent.op} cannot submit unshielded"
615-
if intent.mev_shield_required
615+
f"{semantic_intent.op} cannot submit unshielded"
616+
if semantic_intent.mev_shield_required
616617
else "pass --no-mev-shield to submit unshielded "
617618
"(alpha fees work on the bare call), or fund the "
618619
"signing account with free TAO"
@@ -816,13 +817,13 @@ async def _execute(client):
816817
# The MevShield pallet isn't active here (e.g. localnet). A
817818
# forced / required shield must fail loudly; the built-in
818819
# default degrades visibly so the command still works.
819-
if shield_forced or intent.mev_shield_required:
820+
if shield_forced or semantic_intent.mev_shield_required:
820821
raise BittensorError(
821822
"MEV shield is not active on this network "
822823
"(MevShield.NextKey is unset); "
823824
+ (
824-
f"{intent.op} cannot submit unshielded"
825-
if intent.mev_shield_required
825+
f"{semantic_intent.op} cannot submit unshielded"
826+
if semantic_intent.mev_shield_required
826827
else "pass --no-mev-shield to submit unshielded"
827828
)
828829
)
@@ -844,13 +845,13 @@ async def _execute(client):
844845
else None
845846
)
846847
if shortfall is not None:
847-
if shield_forced or intent.mev_shield_required:
848+
if shield_forced or semantic_intent.mev_shield_required:
848849
raise BittensorError(
849850
"MEV-shielded submission needs free TAO for the outer "
850851
"carrier fee; "
851852
+ (
852-
f"{intent.op} cannot submit unshielded"
853-
if intent.mev_shield_required
853+
f"{semantic_intent.op} cannot submit unshielded"
854+
if semantic_intent.mev_shield_required
854855
else "pass --no-mev-shield to submit unshielded "
855856
"(alpha fees work on the bare call)"
856857
)

sdk/python/bittensor/cli/multisig_helpers.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,13 @@ def wrap_intent_for_multisig_wallet(app_ctx, intent):
388388
389389
Raises ``ValueError`` when the preset or local signatory set is unusable.
390390
"""
391-
from ..intents.multisig import MultisigExecute, MultisigThreshold1, _compose_inner
391+
from ..intents.multisig import (
392+
MultisigExecute,
393+
MultisigIntentAdapter,
394+
MultisigThreshold1,
395+
MultisigThreshold1IntentAdapter,
396+
_compose_inner,
397+
)
392398

393399
if getattr(intent, "signer", None) != "coldkey":
394400
return intent
@@ -414,7 +420,8 @@ def wrap_intent_for_multisig_wallet(app_ctx, intent):
414420
app_ctx.output.message(
415421
f"[dim]dispatching via 1-of-{len(signatories)} multisig {preset}[/dim]"
416422
)
417-
return MultisigThreshold1(other_signatories=others, call=call_dict)
423+
dispatch = MultisigThreshold1(other_signatories=others, call=call_dict)
424+
return MultisigThreshold1IntentAdapter(dispatch=dispatch, semantic=intent)
418425

419426
async def _timepoint(client):
420427
wallet = wallets.open_wallet(member_name, path=app_ctx.wallet_path)
@@ -433,12 +440,13 @@ async def _timepoint(client):
433440
f"[dim]{action} via {threshold}-of-{len(signatories)} multisig {preset} "
434441
f"as {format_signatory_display(signer_ss58, member_name)}[/dim]"
435442
)
436-
return MultisigExecute(
443+
dispatch = MultisigExecute(
437444
threshold=threshold,
438445
other_signatories=others,
439446
call=call_dict,
440447
timepoint=timepoint,
441448
)
449+
return MultisigIntentAdapter(dispatch=dispatch, semantic=intent)
442450

443451

444452
async def multisig_list_records(

sdk/python/bittensor/executor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ async def plan(
523523
violations=violations,
524524
call=call,
525525
extras=extras,
526-
spend=intent.spend(),
526+
spend=intent.semantic_intent().spend(),
527527
args={k: v for k, v in intent.to_dict().items() if k != "op"},
528528
)
529529

@@ -565,7 +565,7 @@ async def execute(
565565
return the queue receipt instead. ``registration_timeout`` and the
566566
optional ``on_progress(dict)`` callback apply only to that wait.
567567
"""
568-
if intent.mev_shield_required:
568+
if intent.semantic_intent().mev_shield_required:
569569
if proxy_for is not None:
570570
raise BittensorError(
571571
f"{intent.op} must be submitted MEV-shielded and cannot "

sdk/python/bittensor/intents/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ def affects_all_subnets(self) -> bool:
244244
"""True if the intent acts across every subnet (so any allowlist must fail it)."""
245245
return False
246246

247+
def semantic_intent(self) -> "Intent":
248+
"""Intent whose safety contract governs this submission.
249+
250+
Execution adapters may wrap a call without changing the spend, subnet,
251+
or MEV requirements of the operation being dispatched.
252+
"""
253+
return self
254+
247255
# Introspection ----------------------------------------------------------
248256

249257
@classmethod

sdk/python/bittensor/intents/multisig.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,65 @@ def summary(self) -> str:
221221
)
222222

223223

224+
@dataclass
225+
class MultisigIntentAdapter(Intent):
226+
"""Keep an inner intent's safety contract while dispatching it by multisig.
227+
228+
Saved-multisig CLI wallets turn a regular coldkey intent into one of the
229+
concrete multisig intents above. The concrete intent owns call composition,
230+
while ``semantic`` remains authoritative for policy scope and MEV handling.
231+
This adapter is internal and deliberately unregistered: it is execution
232+
state, not a separate operation exposed by the SDK.
233+
"""
234+
235+
op = "multisig_execute"
236+
signer = "coldkey"
237+
238+
dispatch: MultisigExecute | MultisigThreshold1 = field(repr=False)
239+
semantic: Intent = field(repr=False)
240+
241+
@property
242+
def threshold(self) -> int:
243+
return int(getattr(self.dispatch, "threshold", 1))
244+
245+
@property
246+
def other_signatories(self) -> list:
247+
return self.dispatch.other_signatories
248+
249+
async def build(self, substrate, wallet: Any):
250+
return await self.dispatch.build(substrate, wallet)
251+
252+
def summary(self) -> str:
253+
return self.dispatch.summary()
254+
255+
async def effects(self, substrate, signer_address: str) -> list[str]:
256+
return await self.dispatch.effects(substrate, signer_address)
257+
258+
async def warnings(self, substrate, signer_address: str) -> list[str]:
259+
return await self.dispatch.warnings(substrate, signer_address)
260+
261+
def spend(self):
262+
return self.semantic.spend()
263+
264+
def touches_netuids(self) -> list[int]:
265+
return self.semantic.touches_netuids()
266+
267+
def affects_all_subnets(self) -> bool:
268+
return self.semantic.affects_all_subnets()
269+
270+
def semantic_intent(self) -> Intent:
271+
return self.semantic
272+
273+
def to_dict(self) -> dict[str, Any]:
274+
return self.dispatch.to_dict()
275+
276+
277+
class MultisigThreshold1IntentAdapter(MultisigIntentAdapter):
278+
"""Safety-preserving adapter for immediate 1-of-N dispatch."""
279+
280+
op = "multisig_threshold_1"
281+
282+
224283
@register
225284
@dataclass
226285
class MultisigApprove(Intent):

sdk/python/bittensor/intents/plan.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def check_raw_call(self) -> list[str]:
4949
return ["raw call submission is disabled by policy (set allow_raw_calls=True)"]
5050

5151
def check(self, intent: Intent, fee: Optional[Balance]) -> list[str]:
52+
intent = intent.semantic_intent()
5253
violations: list[str] = []
5354
if self.max_fee_tao is not None:
5455
# A fee cap must not fail open: an unavailable estimate blocks
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Safety invariants for automatic saved-multisig intent wrapping."""
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import AsyncMock, Mock
5+
6+
import pytest
7+
8+
from bittensor import Policy
9+
from bittensor.cli import multisig_helpers
10+
from bittensor.client import Client
11+
from bittensor.executor import Executor
12+
from bittensor.intents._money import UNBOUNDED
13+
from bittensor.intents.multisig import (
14+
MultisigThreshold1,
15+
MultisigThreshold1IntentAdapter,
16+
)
17+
from bittensor.intents.registration import BurnedRegister
18+
from tests.harness.fake_substrate import FakeSubstrate
19+
from tests.harness.samples import ALICE, ALICE_HOT, BOB, dev_wallet
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_saved_multisig_preserves_inner_policy_and_mev_contract(monkeypatch):
24+
output = Mock()
25+
app_ctx = SimpleNamespace(
26+
wallet_name="treasury",
27+
wallet_path="/unused",
28+
wallet_given=True,
29+
multisig_wallet_name=None,
30+
output=output,
31+
)
32+
monkeypatch.setattr(multisig_helpers.cfg, "get_multisig", lambda name: {"name": name})
33+
monkeypatch.setattr(
34+
multisig_helpers,
35+
"resolve_multisig_preset",
36+
lambda _app_ctx, _preset: (1, [ALICE, BOB], ["alice", "bob"]),
37+
)
38+
monkeypatch.setattr(
39+
multisig_helpers,
40+
"pick_local_signatory",
41+
lambda _app_ctx, *, preset, signatories: ("alice", ALICE),
42+
)
43+
semantic = BurnedRegister(netuid=7, hotkey_ss58=ALICE_HOT)
44+
45+
wrapped = multisig_helpers.wrap_intent_for_multisig_wallet(app_ctx, semantic)
46+
47+
assert isinstance(wrapped, MultisigThreshold1IntentAdapter)
48+
assert wrapped.op == "multisig_threshold_1"
49+
assert wrapped.semantic_intent() is semantic
50+
assert wrapped.semantic_intent().mev_shield_default is True
51+
assert wrapped.semantic_intent().mev_shield_required is True
52+
assert wrapped.spend() is UNBOUNDED
53+
assert wrapped.touches_netuids() == [7]
54+
assert wrapped.affects_all_subnets() is False
55+
56+
violations = Policy(max_spend_tao=1, allowed_netuids=[1]).check(wrapped, fee=None)
57+
assert any("cannot be bounded" in violation for violation in violations)
58+
assert any("netuid 7" in violation for violation in violations)
59+
60+
plan = await Client("local", substrate=FakeSubstrate()).plan(
61+
wrapped,
62+
dev_wallet(),
63+
policy=Policy(max_spend_tao=1, allowed_netuids=[1]),
64+
)
65+
assert plan.spend is UNBOUNDED
66+
assert plan.violations == violations
67+
68+
assert app_ctx.multisig_wallet_name == "treasury"
69+
assert app_ctx.wallet_name == "alice"
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_required_mev_shield_survives_multisig_dispatch():
74+
semantic = BurnedRegister(netuid=7, hotkey_ss58=ALICE_HOT)
75+
dispatch = MultisigThreshold1(
76+
other_signatories=[BOB],
77+
call=semantic.to_dict(),
78+
)
79+
wrapped = MultisigThreshold1IntentAdapter(dispatch=dispatch, semantic=semantic)
80+
executor = Executor(Mock())
81+
expected = Mock()
82+
executor.submit_shielded = AsyncMock(return_value=expected)
83+
wallet = Mock()
84+
85+
result = await executor.execute(wrapped, wallet)
86+
87+
assert result is expected
88+
executor.submit_shielded.assert_awaited_once_with(
89+
wrapped,
90+
wallet,
91+
policy=None,
92+
wait_for_inclusion=True,
93+
wait_for_finalization=True,
94+
)

0 commit comments

Comments
 (0)