Skip to content

feat(manager): DAO-governed MPC signing via v1.signer (ENG-718) - #633

Draft
peer2f00l wants to merge 13 commits into
devfrom
feature/eng-718-tmplrmgr-dao-governed-mpc-signing-via-v1signer
Draft

peer2f00l wants to merge 13 commits into
devfrom
feature/eng-718-tmplrmgr-dao-governed-mpc-signing-via-v1signer

Conversation

@peer2f00l

@peer2f00l peer2f00l commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Closes ENG-718. Builds on #631 (ENG-719) and #632 (ENG-720), both merged; the diff is the tmplrmgr work alone.

Let a Sputnik DAO authorize transactions on accounts it controls through an MPC-derived full-access key (v1.signer / v1.signer-prod.testnet), without near-cli's "keep the terminal open until the vote passes" flow.

Why not near-cli-rs

  • sign-with-mpc … submit-mpc-as-dao-proposal holds the inner unsigned tx (nonce, block hash) only in process memory, never prints it, and blocks on an interactive prompt until the proposal is executed. Ctrl-C loses the tx; sign-later/--offline are not offered on that path; sign-transaction <base64> drops nonce/block_hash. No upstream fix (submit-mpc-as-dao-proposal: cannot recover the MPC signature when the DAO proposal was executed via a meta-transaction (Delegate action) near/near-cli-rs#669 is the meta-tx recovery bug).
  • A plain tx is valid for transaction_validity_period = 86400 blocks ≈ 15 h on mainnet; our DAO's proposal_period is 7 days.
  • The MPC contract stores nothing after respond: the signature exists only as the SuccessValue of the v1.signer self-receipt in the receipt chain of whichever tx executed the proposal.

Design

The DAO proposal is the single source of truth. Its sign action carries the hash (payload_v2); the unsigned bytes that hash to it live in the proposal description (tmplrmgr-mpc:{…} line) or, with --blind --out <file>, only in a local payload file. Same envelope JSON either way, so the two are interchangeable for show/relay --payload-file.

Two payload kinds behind one SignablePayload enum:

  • delegate-action (default): NEP-461 hash of a DelegateAction { sender_id, nonce, max_block_height, actions, public_key: derived }. No block-hash expiry; relayed by any funded account via tx.relaySignedDelegateAction.
  • transaction: plain Transaction::V0 hash (near-cli compatible); expiry is the on-chain height of its block hash plus the 86400-block window, refused before broadcast. broadcast --print emits the signed tx as base64 borsh for near transaction send-signed-transaction.

Commands

  • tmplrmgr mpc derive-key --dao --account-id [--path]derived_public_key view.
  • tmplrmgr mpc install-key --dao [--path] <SignerArgs> — derives the key and adds it to the signer with full access (account.addKey).
  • tmplrmgr mpc propose --plan <--print json output> --dao [--path] [--kind] [--nonce] [--valid-for-blocks] [--blind] [--out] <proposer SignerArgs> — checks the derived key is a full-access key on the controlled account, builds the payload, writes --out first (temp+rename), submits add_proposal with deposit = policy.proposal_bond. Honours --print.
  • tmplrmgr mpc show --dao --proposal-id [--payload-file] — decodes the payload, verifies hash(bytes) == payload_v2, that the payload's key is the one the DAO derives at the proposal's path, and that the sign call attaches exactly 1 yoctoNEAR; prints actions, deposit/gas, status, expiry. Exits non-zero on any mismatch.
  • tmplrmgr mpc relay … <relayer SignerArgs> (delegate action) and tmplrmgr mpc broadcast … [--print] (transaction; no credentials) — both take --dao --proposal-id --tx-hash --tx-signer [--payload-file], run the same checks, require Approved and unexpired, read the MPC receipts via tx.get, and pick the signature that verifies against the derived key + hash (so meta-transaction- and multi-proposal executions work). --tx-signer is needed because NEAR resolves a transaction by hash and sender. The wrong command for a payload kind is an explicit error.
  • tmplrmgr account add-key --key [--receiver-id [--method-name …] [--allowance]] over account.addKey (full access unless --receiver-id), and tmplrmgr account delete-key --key over account.deleteKey. Both are plain gateway writes with no MPC coupling.
  • Network::mpc_contract_id() in templar-gateway-client.

Tests

  • Unit: envelope round-trip (description ⇄ file), version guard, single-marker rule, temp-file safety; payload/domain pairing; refused non-1-yocto sign deposit; payload build/decode/hash for both kinds, tx hash cross-checked against near-primitives; SignatureResponse parsing (ed25519 + secp256k1), wrong-key rejection; DAO proposal parsing from a real mainnet get_proposal shape; clap matrix (--blind requires --out, add-key permission flags, install-key default path).
  • Sandbox e2e (requires_sandbox_mpc_proposal_is_relayed_end_to_end, rstest cases public delegate action and blind transaction) against two new mocks, mock-signer and mock-dao: install-key → add-key/delete-key of a function-call key → a rejected --nonce → propose → pre-vote show → a decoy payload refused by show → the test signs the hash the proposal carries as the "MPC" → act_proposal → show → the wrong send command refused → the decoy refused by relay/broadcast → relay/broadcast → the controlled account's transfer lands. The blind case also asserts show fails without --payload-file. A second test (requires_sandbox_mpc_expired_payload_is_refused_before_broadcast) approves a short-lived delegate action, fast-forwards past it, and asserts relay refuses it.

Not yet done

  • Manual run against v1.signer-prod.testnet with a testnet Sputnik DAO (the only place the real yield/resume + Sputnik callback chain is exercised).

Docs

docs/src/architecture.md (Chain Signatures trust row) and docs/src/governance.md (new DAO-approved MPC signing section) describe the derived-key path this adds.

Verification

  • just test-fast -p templar-manager -p templar-gateway-client -p templar-contract-artifacts -p templar-gateway-testing (475 passed)
  • just test-sandbox -p templar-manager -E 'test(requires_sandbox_mpc)'
  • cargo fmt --all --check; cargo clippy --workspace --all-features --tests -- -D warnings

🤖 Generated with Claude Code

https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2


This change is Reviewable

peer2f00l and others added 4 commits September 11, 2026 15:21
Adds a full-access key to the signing account, modelled on account.delete.
Needed to install an MPC-derived key for DAO-governed signing (ENG-718).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
`GetResult.receipts` lists every receipt with its executor, status, logs and
return value, so a value an inner receipt returned (e.g. an MPC signature
inside a DAO `act_proposal` chain, ENG-718) is reachable through the gateway.

Asserting on it exposed that `mock-receiver` never worked: its `#[near]`
parameters were underscore-prefixed, which is the JSON field name the wrapper
deserializes, so every `*_on_transfer` panicked and the token contract
refunded in full while the transaction still reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
…ture/eng-720-gateway-txget-returns-per-receipt-outcomes' into feature/eng-718-tmplrmgr-dao-governed-mpc-signing-via-v1signer
`tmplrmgr mpc {derive-key, propose, show, relay}` has a Sputnik DAO ask the
NEAR MPC signer to sign a planned transaction, with the proposal as the single
source of truth: its `sign` action carries the hash and, unless `--blind`, its
description carries the exact bytes that hash to it. Either can be reviewed and
relayed later by anyone from `--dao --proposal-id` (plus the payload file for a
blind proposal), so nothing depends on the proposer's session.

Two payload kinds: a NEP-366 delegate action (default; expires at a block
height, relayed via tx.relaySignedDelegateAction) and a plain transaction
(near-cli compatible; bound to a block hash the CLI tracks). The signature is
read from the executing transaction's receipts and verified against the derived
key before relay, which also covers meta-transaction-executed proposals.

Also `tmplrmgr account add-key` (literal or MPC-derived key) and
`Network::mpc_contract_id`. Sandbox e2e runs against `mock-signer` and
`mock-dao`, new mocks of the two third-party contracts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

peer2f00l and others added 9 commits September 14, 2026 09:41
…ey (ENG-719)

`addKey` carries the same `AccessKeyPermission` `getAccessKey` reports, so
function-call keys (receiver, method names, allowance) are installable, not
only full access. `deleteKey` is the inverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
…re/eng-718-tmplrmgr-dao-governed-mpc-signing-via-v1signer
…G-718)

`--receiver-id [--method-name …] [--allowance]` installs a function-call key;
omitted, the key is full access as before. `delete-key` is the inverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
Round-trip both permission variants in the unit test, move the receiver id
into near-api's String instead of formatting it, and update the docs the new
methods made stale: `ActionInput`'s allowlist rationale and the permission
enum's read-only wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
…re/eng-718-tmplrmgr-dao-governed-mpc-signing-via-v1signer
…718)

`account add-key --key` is a plain gateway write again; the derived-key
install moves to `mpc install-key`, which reuses the `--dao/--path` args
`derive-key` and `propose` already take. The old form duplicated the
default-path convention, accepted `--mpc-contract` it then ignored, and could
install a function-call MPC key that `propose` rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
…ind (ENG-718)

Review findings on #633:

- `mpc relay` (delegate action, needs a fee payer) and `mpc broadcast`
  (transaction, needs nobody; `--print` emits the signed bytes) replace the
  single command that demanded relayer credentials for both.
- The envelope carries bytes only: a transaction's expiry is derived from the
  on-chain height of the block hash inside the signed bytes, not from
  unsigned metadata that could be edited past the hash check.
- `show` reports the `sign` call's deposit and gas and refuses a deposit other
  than the 1 yoctoNEAR tmplrmgr attaches.
- Among several `sign` receipts in one execution, the signature that verifies
  over this payload is chosen; `--nonce` below the key's next nonce is refused;
  a payload variant must match its signing domain; a description may carry
  exactly one envelope marker; the payload file is written through a fresh
  sibling temp file.
- E2E covers pre-vote review, decoy payloads on show/relay/broadcast, the
  wrong send command, a rejected nonce, and an expired approved payload.
- Docs: the Chain Signatures trust boundary now describes the derived-key,
  DAO-approved path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5amj1VuS6TPoAyxeFroV2
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