Skip to content

feat(contract): per-party contract cosigner group (1 cosigner, 1 user)#49

Closed
aruokhai wants to merge 1 commit into
v3from
claude/contract-cosigner-group-dmi5o4
Closed

feat(contract): per-party contract cosigner group (1 cosigner, 1 user)#49
aruokhai wants to merge 1 commit into
v3from
claude/contract-cosigner-group-dmi5o4

Conversation

@aruokhai

Copy link
Copy Markdown
Contributor

Problem

The multi-user contract path stood up a single shared cosigner actor keyed by the cooperative key V′. That one actor held every recipient's counter-share C_i in a single recipient_shares map behind an N-entry authorization roster (group_auth_idx[V′]), and every party resolved to it via policy_owner_idx[V′] = V′.

So N parties wrote into one actor's storage namespace. This violates the one-cosigner-one-user invariant the rest of the system holds (a normal wallet is 1 cosigner = 1 id = its group key = 1 user), and it defeats per-user enclave rollback detection: a user can't keep a coherent post-write checksum chain when other parties interleave writes into the same actor's state.

Fix — a contract cosigner group

Introduce a first-class ContractCosignerGroup (persisted under contract_groups/<V′>) that binds a group of dedicated per-party cosigners and carries the cross-party onboarding context — the cosigner's canonical V′ key package and the eVTXO taptree params — which legitimately cannot belong to any single party.

Each party now gets its own dedicated contract cosigner, keyed by

cc_id = sha256("merlin:contract-cosigner-group:v1" || V′ || recipient_vk)

holding only that party's C_i (a single-recipient EvtxoPolicy) behind a single-entry roster — so the global invariant holds for contracts too: 1 cosigner ⇔ 1 id (its group key) ⇔ 1 user, each with its own isolated write stream.

Wire protocol is unchanged

Clients still address the group by V′ and send their own claimed_share. The change is entirely server-side:

  • The host fans a cooperative spend out to the spending party's cc_id (rest_api SignStep1/2 routing), so the spend lands on an isolated single-user actor.
  • The addressed V′ actor becomes a thin group coordinator that owns onboarding only and authenticates just the author (so it too is 1 actor ⇔ 1 user).
  • One-shot V′ == V single-author eVTXOs (already single-user, never a group) are untouched — spend_route returns None and the route is unchanged.

Changes

File What
contract/group.rs (new) ContractCosignerGroup, cc_id derivation, register_member, spend_route fan-out resolver, persistence. Unit-tested for determinism + per-party isolation.
onboarding/evtxo.rs Creation builds the group + the author's dedicated cosigner + a coordinator policy, instead of one shared V′ actor.
cosigner/handlers/onboard.rs Onboarding reads context from the group record and mints the participant's own dedicated cosigner (no shared roster growth).
rest_api.rs SignStep1/SignStep2 fan out (V′, claimed_share) → cc_id.
protocol/protos/mpc_wallet.proto Doc-only: describe the per-party fan-out.

Testing

  • cargo check (workspace) — clean.
  • New unit tests in contract::group pass: cc_id determinism + per-party uniqueness, and register_member isolation (each cosigner's policy holds only its own recipient share, single-entry roster, correct self-route + group fan-out).
  • Pre-existing contract_gate tests still require the example WASM artifacts to be pre-built (they panic with "build the contract first" on a bare checkout — unrelated to this change; CI builds them).

Notes / scope

Per the agreed scope this is the structural regrouping only. The per-write rollback checksum mechanism it enables (returning a monotonic checksum to each party so clients can detect host rollback) is a follow-up that builds on these now-isolated per-party write streams. End-to-end enclave verification of the new actor topology is recommended before merge.

https://claude.ai/code/session_011FeY2KHoRDKnXeAGqx8EzB


Generated by Claude Code

The multi-user contract path stood up a single shared cosigner actor keyed
by the cooperative key V′ that held EVERY recipient's counter-share C_i
behind an N-entry authorization roster. N parties therefore wrote into one
actor's storage namespace, which violates the one-cosigner-one-user
invariant and defeats per-user enclave rollback detection: a user cannot
keep a coherent post-write checksum chain when other parties interleave
writes into the same actor's state.

Introduce a ContractCosignerGroup abstraction (contract_groups/<V′>) that
binds a group of dedicated per-party cosigners and carries the cross-party
onboarding context (the cosigner's canonical V′ key package + the eVTXO
taptree params) that cannot belong to any single party. Each party now gets
its OWN dedicated contract cosigner, keyed by cc_id = sha256(V′||recipient_vk),
holding ONLY that party's C_i (single-recipient policy) behind a single-entry
roster — so 1 cosigner ⇔ 1 id (its group key) ⇔ 1 user, each with an isolated
write stream.

The wire protocol is unchanged: clients still address the group by V′ and
send their own claimed_share. The host fans a cooperative spend out to the
spending party's cc_id (rest_api sign routing), and the addressed V′ actor
becomes a thin group COORDINATOR that owns onboarding only and authenticates
just the author.

- contract/group.rs: ContractCosignerGroup, cc_id derivation, register_member,
  spend_route fan-out resolver, persistence; unit-tested for determinism +
  per-party isolation.
- onboarding/evtxo.rs: creation builds the group + the author's dedicated
  cosigner + a coordinator policy instead of one shared V′ actor.
- handlers/onboard.rs: onboarding reads context from the group record and
  mints the participant's own dedicated cosigner.
- rest_api.rs: SignStep1/2 fan out (V′, claimed_share) → cc_id.

https://claude.ai/code/session_011FeY2KHoRDKnXeAGqx8EzB

Copy link
Copy Markdown
Contributor Author

Closing without merging.

The load-bearing invariant is 1 cosigner = 1 group key. A contract cosigner's group key is V′, and V′ is shared by everyone who can spend the same eVTXO — by construction of the shared cooperative leaf. So 1 cosigner = 1 user cannot coexist with it on a shared eVTXO: this PR only achieved "one user per cosigner" by addressing each party with a derived cc_id = sha256(V′‖vk) that is not a group key — i.e. it weakened the more important invariant to satisfy a lesser one.

The existing design is already the right shape: one V′ cosigner (one group key) whose group_auth_idx[V′] roster is the contract cosigner group of member users.

Per-user rollback-checksum isolation is still worth doing, but needs a cleverer approach that keeps V′ as a single cosigner — e.g. a per-member checksum chain maintained inside the shared cosigner's namespace (keyed by member vk), not a split of the cosigner identity. Deferring that to a separate effort.


Generated by Claude Code

@aruokhai aruokhai closed this Jun 14, 2026
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.

2 participants