diff --git a/control-plane/cmd/controlplane-admin/main.go b/control-plane/cmd/controlplane-admin/main.go index 91862ed..6147336 100644 --- a/control-plane/cmd/controlplane-admin/main.go +++ b/control-plane/cmd/controlplane-admin/main.go @@ -1,21 +1,40 @@ -// Command controlplane-admin is operator tooling for issue #12: there is -// deliberately no self-service user registration RPC (that would be a much -// larger surface -- verification, abuse prevention -- out of scope for a -// tenancy MVP), so creating a user and issuing their first API key is a -// local/offline operation against the same Postgres the Control Plane uses. +// Command controlplane-admin is operator tooling for two unrelated +// privileged actions that both need the Control Plane's own credentials +// rather than an ordinary user's or validator's: // -// The raw API key is printed exactly once, to stdout, and never persisted -// anywhere (only its SHA-256 hash lives in Postgres) -- copy it immediately; -// there is no way to recover it later, only to revoke it and issue a new one. +// - User/API-key management (issue #12): there is deliberately no +// self-service user registration RPC (that would be a much larger +// surface -- verification, abuse prevention -- out of scope for a +// tenancy MVP), so creating a user and issuing their first API key is +// a local/offline Postgres operation. The raw API key is printed +// exactly once, to stdout, and never persisted anywhere (only its +// SHA-256 hash lives in Postgres) -- copy it immediately; there is no +// way to recover it later, only to revoke it and issue a new one. +// - resolve-dispute (ADR-013 slice 5, issue #78): pallet-network- +// validator's resolve_dispute extrinsic is SuspensionOrigin-gated +// (EnsureRoot in this runtime) -- only the Control Plane's own +// bridge/sudo account can call it, the same governance trust +// boundary provider registration's EnsureActive already uses. This is +// why it lives here and not in cmd/networkvalidator, which +// deliberately only ever signs with an ordinary validator's own +// account. +// +// Each subcommand only connects to the credential store it actually +// needs -- user/API-key commands never touch the chain, resolve-dispute +// never touches Postgres. package main import ( "context" + "encoding/hex" "errors" "fmt" + "net/http" "os" + "strconv" "github.com/jackc/pgx/v5/pgxpool" + "github.com/openinfra/network/internal/blockchainbridge" "github.com/openinfra/network/internal/userauth" "github.com/openinfra/network/migrations" ) @@ -31,11 +50,23 @@ func run(args []string) error { if len(args) == 0 { return usageError() } + ctx := context.Background() + + switch args[0] { + case "create-user", "issue-key", "revoke-key": + return runUserCommand(ctx, args) + case "resolve-dispute": + return runResolveDispute(ctx, args) + default: + return usageError() + } +} + +func runUserCommand(ctx context.Context, args []string) error { databaseURL := os.Getenv("DATABASE_URL") if databaseURL == "" { return errors.New("DATABASE_URL is required") } - ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return fmt.Errorf("configure PostgreSQL: %w", err) @@ -66,9 +97,8 @@ func run(args []string) error { } fmt.Println("revoked") return nil - default: - return usageError() } + return usageError() } func createUser(ctx context.Context, repository *userauth.PostgresRepository, displayName string) error { @@ -90,6 +120,82 @@ func issueKey(ctx context.Context, repository *userauth.PostgresRepository, user return nil } +// runResolveDispute settles a dispute pallet-network-validator's +// resolve_dispute -- see the package doc comment for why this, uniquely +// among this session's Network Validator tooling, must run with the +// Control Plane's own bridge/sudo credentials rather than a validator's. +func runResolveDispute(ctx context.Context, args []string) error { + if len(args) != 5 { + return errors.New("usage: controlplane-admin resolve-dispute ") + } + rpcURL := os.Getenv("SUBSTRATE_RPC_URL") + if rpcURL == "" { + return errors.New("SUBSTRATE_RPC_URL is required") + } + signerKeyFile := os.Getenv("SUBSTRATE_SIGNER_KEY_FILE") + if signerKeyFile == "" { + return errors.New("SUBSTRATE_SIGNER_KEY_FILE is required (the Control Plane's own bridge/sudo key -- resolve_dispute is SuspensionOrigin-gated, an ordinary validator's key cannot call it)") + } + provider, err := parseAccountHex(args[1]) + if err != nil { + return fmt.Errorf("parse provider: %w", err) + } + round, err := strconv.ParseUint(args[2], 10, 64) + if err != nil { + return fmt.Errorf("parse round: %w", err) + } + dimension, err := blockchainbridge.ParseScoreDimension(args[3]) + if err != nil { + return err + } + uphold, err := parseUpholdOrReject(args[4]) + if err != nil { + return err + } + + chain, err := blockchainbridge.NewRPCClient(rpcURL, &http.Client{}) + if err != nil { + return fmt.Errorf("configure Substrate RPC client: %w", err) + } + registrar, err := blockchainbridge.NewRegistrarFromPKCS8File(chain, signerKeyFile) + if err != nil { + return fmt.Errorf("configure bridge signer: %w", err) + } + if err := registrar.ResolveDispute(ctx, provider, round, dimension, uphold); err != nil { + return fmt.Errorf("resolve_dispute: %w", err) + } + verb := "rejected (round's aggregate re-applied)" + if uphold { + verb = "upheld (rollback to the pre-round score kept)" + } + fmt.Printf("resolve_dispute submitted for provider=%s round=%d dimension=%s: %s\n", args[1], round, dimension, verb) + return nil +} + +func parseAccountHex(value string) ([32]byte, error) { + var account [32]byte + decoded, err := hex.DecodeString(value) + if err != nil { + return account, fmt.Errorf("%q is not valid hex: %w", value, err) + } + if len(decoded) != 32 { + return account, fmt.Errorf("%q decodes to %d bytes, want 32", value, len(decoded)) + } + copy(account[:], decoded) + return account, nil +} + +func parseUpholdOrReject(value string) (bool, error) { + switch value { + case "uphold": + return true, nil + case "reject": + return false, nil + default: + return false, fmt.Errorf("%q must be exactly \"uphold\" or \"reject\"", value) + } +} + func usageError() error { - return errors.New("usage: controlplane-admin | issue-key | revoke-key >") + return errors.New("usage: controlplane-admin | issue-key | revoke-key | resolve-dispute >") } diff --git a/control-plane/cmd/networkvalidator/main.go b/control-plane/cmd/networkvalidator/main.go index adb1b5c..d547e6f 100644 --- a/control-plane/cmd/networkvalidator/main.go +++ b/control-plane/cmd/networkvalidator/main.go @@ -1,23 +1,26 @@ // Command networkvalidator is a Network Validator's full local process // (ADR-013, docs/adr/013-network-validator-daemon.md): identity and -// lifecycle (register/status/request-exit/withdraw, slice 1) plus the +// lifecycle (register/status/request-exit/withdraw, slice 1), the // continuous challenge loop (run, slice 4 / issue #78) that discovers // assigned providers, calls their Agent's SolveChallenge over mTLS, // scores the response, and submits evidence/closes rounds on -// pallet-network-validator. Every extrinsic is signed directly by this +// pallet-network-validator, and disputing a closed round (dispute, slice +// 5) as a deliberate, attributable human action -- never automated by the +// challenge loop itself. Every extrinsic here is signed directly by this // binary's own operator-supplied key -- never sudo-wrapped, never routed // through the Control Plane -- the exact trust boundary ADR-011 // introduced. // -// Still out of scope (see docs/adr/013-network-validator-daemon.md's -// sequencing and issue #78): dispute_round/resolve_dispute handling -// (ADR-013 slice 5, a deliberate, attributable human action, not -// automated). +// resolve_dispute (also slice 5) is deliberately not in this binary: the +// pallet gates it to SuspensionOrigin (EnsureRoot in this runtime), so +// only the Control Plane's own bridge/sudo account can call it -- see +// cmd/controlplane-admin's resolve-dispute instead. package main import ( "context" "crypto/x509" + "encoding/hex" "errors" "fmt" "net/http" @@ -102,11 +105,54 @@ func run(args []string) error { return errors.New("usage: networkvalidator run") } return runLoop(chain, registrar) + case "dispute": + if len(args) != 4 { + return errors.New("usage: networkvalidator dispute ") + } + return dispute(ctx, registrar, args[1], args[2], args[3]) default: return usageError() } } +// dispute is ADR-013 slice 5's manual, explicit CLI action (deliberately +// not something the challenge loop triggers automatically -- see +// Registrar.DisputeRound's doc comment). Only the validator side of +// dispute_round's authorization is reachable from this binary: a +// provider has no independent chain-signing path in this MVP. +func dispute(ctx context.Context, registrar *blockchainbridge.Registrar, providerHex, roundArg, dimensionArg string) error { + provider, err := parseAccountHex(providerHex) + if err != nil { + return fmt.Errorf("parse provider: %w", err) + } + round, err := strconv.ParseUint(roundArg, 10, 64) + if err != nil { + return fmt.Errorf("parse round: %w", err) + } + dimension, err := blockchainbridge.ParseScoreDimension(dimensionArg) + if err != nil { + return err + } + if err := registrar.DisputeRound(ctx, provider, round, dimension); err != nil { + return fmt.Errorf("dispute_round: %w", err) + } + fmt.Printf("dispute_round submitted for provider=%s round=%d dimension=%s; the score rolled back to its pre-round value pending resolve_dispute\n", providerHex, round, dimension) + return nil +} + +func parseAccountHex(value string) ([32]byte, error) { + var account [32]byte + decoded, err := hex.DecodeString(value) + if err != nil { + return account, fmt.Errorf("%q is not valid hex: %w", value, err) + } + if len(decoded) != 32 { + return account, fmt.Errorf("%q decodes to %d bytes, want 32", value, len(decoded)) + } + copy(account[:], decoded) + return account, nil +} + func printStatus(ctx context.Context, chain *blockchainbridge.RPCClient, registrar *blockchainbridge.Registrar) error { account := registrar.Account() record, found, err := chain.FinalizedValidatorRecord(ctx, account) @@ -214,5 +260,5 @@ func runLoop(chain *blockchainbridge.RPCClient, registrar *blockchainbridge.Regi } func usageError() error { - return errors.New("usage: networkvalidator | status | request-exit | withdraw | run>") + return errors.New("usage: networkvalidator | status | request-exit | withdraw | run | dispute >") } diff --git a/control-plane/internal/blockchainbridge/networkvalidatorregistrar.go b/control-plane/internal/blockchainbridge/networkvalidatorregistrar.go index 1425eca..8ca159b 100644 --- a/control-plane/internal/blockchainbridge/networkvalidatorregistrar.go +++ b/control-plane/internal/blockchainbridge/networkvalidatorregistrar.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "encoding/binary" + "encoding/hex" "errors" "fmt" ) @@ -18,6 +19,8 @@ const ( withdrawUnbondedCallIndex = 2 submitEvidenceCallIndex = 5 closeRoundCallIndex = 6 + disputeRoundCallIndex = 7 + resolveDisputeCallIndex = 8 ) // ScoreDimension mirrors pallet-network-validator::ScoreDimension @@ -38,6 +41,26 @@ const ( DimensionReliability ) +// ParseScoreDimension is String's inverse, for CLI tools that take a +// dimension name as an argument (cmd/networkvalidator's dispute, +// cmd/controlplane-admin's resolve-dispute). +func ParseScoreDimension(name string) (ScoreDimension, error) { + switch name { + case "compute": + return DimensionCompute, nil + case "storage": + return DimensionStorage, nil + case "network": + return DimensionNetwork, nil + case "availability": + return DimensionAvailability, nil + case "reliability": + return DimensionReliability, nil + default: + return 0, fmt.Errorf("unknown score dimension %q (want compute, storage, network, availability, or reliability)", name) + } +} + func (d ScoreDimension) String() string { switch d { case DimensionCompute: @@ -143,6 +166,110 @@ func encodeCloseRoundCall(provider [32]byte, round uint64, dimension ScoreDimens return call } +// DisputeRound submits dispute_round (call_index 7), directly signed by +// this Registrar's own account -- never sudo-wrapped, the pallet itself +// authorizes the caller (the scored provider, or a validator who sat on +// that round's committee; verified directly against +// blockchain/pallets/network-validator/src/lib.rs's dispute_round). ADR- +// 013 §9 deliberately makes this a manual, explicit CLI action rather +// than something the challenge loop triggers automatically: a validator +// disputing algorithmically on every disagreement would just move the +// trust problem, not solve it -- a dispute is a deliberate, attributable +// human decision in this MVP, not machine-automated policy. In this +// architecture only a validator can practically exercise this call today +// (a provider has no independent chain-signing path -- the Provider Agent +// never talks to the chain directly, AGENTS.md's frozen rule -- so the +// provider side of dispute_round's authorization is real on-chain but not +// yet reachable by any tool in this MVP; a future CP-proxied +// "dispute_round_for" delegation, mirroring register_provider_for's +// existing pattern, is the natural way to close that gap, not attempted +// here since it wasn't asked for). +func (r *Registrar) DisputeRound(ctx context.Context, provider [32]byte, round uint64, dimension ScoreDimension) error { + return r.SubmitDirect(ctx, encodeDisputeRoundCall(provider, round, dimension)) +} + +func encodeDisputeRoundCall(provider [32]byte, round uint64, dimension ScoreDimension) []byte { + call := []byte{networkValidatorPalletIndex, disputeRoundCallIndex} + call = append(call, provider[:]...) + call = binary.LittleEndian.AppendUint64(call, round) + call = append(call, byte(dimension)) + return call +} + +// ResolveDispute submits resolve_dispute (call_index 8), settling a +// dispute by upholding (keep the rollback to the round's previous score) +// or rejecting (re-apply the round's aggregate) it. Unlike every other +// method on this type, this call is SuspensionOrigin-gated in the runtime +// (blockchain/runtime/src/lib.rs: SuspensionOrigin = EnsureRoot) -- full +// on-chain adjudication is deferred per ADR-011 §5, so for the MVP a +// governance/root origin decides, the same trust boundary +// provider-registration's EnsureActive already uses. This Registrar must +// therefore be the Control Plane's own bridge/sudo account to call this +// successfully, never an ordinary validator's account -- sudo-wrapped, +// unlike DisputeRound/SubmitEvidence/CloseRound/RegisterValidator, which +// are all deliberately direct-signed to keep validators independent of +// the bridge. +func (r *Registrar) ResolveDispute(ctx context.Context, provider [32]byte, round uint64, dimension ScoreDimension, uphold bool) error { + return r.SubmitSudo(ctx, encodeResolveDisputeCall(provider, round, dimension, uphold)) +} + +func encodeResolveDisputeCall(provider [32]byte, round uint64, dimension ScoreDimension, uphold bool) []byte { + call := []byte{networkValidatorPalletIndex, resolveDisputeCallIndex} + call = append(call, provider[:]...) + call = binary.LittleEndian.AppendUint64(call, round) + call = append(call, byte(dimension)) + // bool's SCALE encoding is a single byte: 0 = false, 1 = true. + if uphold { + call = append(call, 1) + } else { + call = append(call, 0) + } + return call +} + +// SubmitSudo signs and submits call wrapped in Sudo::sudo, using this +// Registrar's own account as the sudo key -- SubmitDirect's sibling for +// the SuspensionOrigin/EnsureRoot-gated calls this package needs +// (ResolveDispute today), mirroring the exact sudo-wrapping +// EnsureActive/EnsureLeaseActive already use, just factored out instead +// of inlined per multi-step call site. +func (r *Registrar) SubmitSudo(ctx context.Context, call []byte) error { + r.mu.Lock() + defer r.mu.Unlock() + + version, err := r.rpc.RuntimeVersion(ctx) + if err != nil { + return err + } + if version.SpecVersion != supportedSpecVersion || version.TransactionVersion != supportedTransactionVersion { + return fmt.Errorf("unsupported runtime version spec=%d transaction=%d", version.SpecVersion, version.TransactionVersion) + } + genesisHex, err := r.rpc.BlockHash(ctx, 0) + if err != nil { + return err + } + genesis, err := fixedHash(genesisHex) + if err != nil { + return err + } + nonce, err := r.finalizedAccountNonce(ctx) + if err != nil { + return err + } + extrinsic, hash, err := r.signSudo(call, nonce, version, genesis) + if err != nil { + return err + } + submitted, err := r.rpc.SubmitExtrinsic(ctx, "0x"+hex.EncodeToString(extrinsic)) + if err != nil { + return err + } + if submitted != "0x"+hex.EncodeToString(hash[:]) { + return errors.New("Substrate returned an unexpected extrinsic hash") + } + return nil +} + // SubmitDirect signs and submits an arbitrary call with this Registrar's // own account, never sudo-wrapped -- the shared version/genesis/nonce // plumbing every directly-signed extrinsic needs (RegisterValidator/ diff --git a/control-plane/internal/blockchainbridge/networkvalidatorregistrar_test.go b/control-plane/internal/blockchainbridge/networkvalidatorregistrar_test.go index 0159a5e..09ba9a8 100644 --- a/control-plane/internal/blockchainbridge/networkvalidatorregistrar_test.go +++ b/control-plane/internal/blockchainbridge/networkvalidatorregistrar_test.go @@ -159,3 +159,67 @@ func TestValidatorStorageKeyIsAPerAccountMapEntry(t *testing.T) { t.Fatal("expected a deterministic storage key for the same account") } } + +func TestEncodeDisputeRoundCallMatchesPalletFieldOrder(t *testing.T) { + var provider [32]byte + provider[0] = 0xCD + round := uint64(99) + dimension := DimensionAvailability + + got := encodeDisputeRoundCall(provider, round, dimension) + + want := []byte{networkValidatorPalletIndex, disputeRoundCallIndex} + want = append(want, provider[:]...) + want = binary.LittleEndian.AppendUint64(want, round) + want = append(want, byte(dimension)) + + if string(got) != string(want) { + t.Fatalf("encodeDisputeRoundCall() = %x, want %x", got, want) + } + if len(got) != 2+32+8+1 { + t.Fatalf("encodeDisputeRoundCall() length = %d, want %d", len(got), 2+32+8+1) + } +} + +func TestEncodeResolveDisputeCallMatchesPalletFieldOrder(t *testing.T) { + var provider [32]byte + provider[0] = 0xEF + round := uint64(7) + dimension := DimensionCompute + + for _, uphold := range []bool{true, false} { + got := encodeResolveDisputeCall(provider, round, dimension, uphold) + + want := []byte{networkValidatorPalletIndex, resolveDisputeCallIndex} + want = append(want, provider[:]...) + want = binary.LittleEndian.AppendUint64(want, round) + want = append(want, byte(dimension)) + if uphold { + want = append(want, 1) + } else { + want = append(want, 0) + } + + if string(got) != string(want) { + t.Fatalf("encodeResolveDisputeCall(uphold=%v) = %x, want %x", uphold, got, want) + } + if len(got) != 2+32+8+1+1 { + t.Fatalf("encodeResolveDisputeCall() length = %d, want %d", len(got), 2+32+8+1+1) + } + } + + // The only byte difference between the two calls must be the final + // uphold flag -- proves the bool encodes as exactly one trailing byte, + // not e.g. a compact-prefixed or multi-byte value. + uphold := encodeResolveDisputeCall(provider, round, dimension, true) + reject := encodeResolveDisputeCall(provider, round, dimension, false) + if len(uphold) != len(reject) { + t.Fatalf("uphold/reject encodings differ in length: %d vs %d", len(uphold), len(reject)) + } + if string(uphold[:len(uphold)-1]) != string(reject[:len(reject)-1]) { + t.Fatal("uphold/reject encodings differ before the final byte") + } + if uphold[len(uphold)-1] != 1 || reject[len(reject)-1] != 0 { + t.Fatalf("final byte = %d/%d, want 1/0", uphold[len(uphold)-1], reject[len(reject)-1]) + } +} diff --git a/docs/adr/013-network-validator-daemon.md b/docs/adr/013-network-validator-daemon.md index 7a26e14..d09fa33 100644 --- a/docs/adr/013-network-validator-daemon.md +++ b/docs/adr/013-network-validator-daemon.md @@ -4,6 +4,27 @@ Accepted. +**Implementation status (post-acceptance):** all five slices from §3 are +now implemented. Slice 5 (round closing/disputes) split across two +binaries by necessity, not by choice: `dispute_round` is directly signed +by the disputing validator's own account (`cmd/networkvalidator dispute`, +alongside slices 1/4's other directly-signed calls), but +`resolve_dispute` is `SuspensionOrigin`-gated (`EnsureRoot` in this +runtime) -- only the Control Plane's own bridge/sudo account can call it, +so it lives in `cmd/controlplane-admin resolve-dispute` instead, sudo- +wrapped the same way provider registration's `EnsureActive` already is. +`dispute_round` is deliberately a manual CLI action, never triggered by +the challenge loop itself (§9's own reasoning: an automated dispute on +every disagreement would just move the trust problem, not solve it). The +pallet's dispute_round also authorizes the scored *provider* to dispute, +not only a committee validator -- that path is real on-chain but not yet +reachable by any tool in this MVP, since a provider has no independent +chain-signing path (AGENTS.md's frozen rule: the Provider Agent never +talks to the chain directly). A future Control-Plane-proxied +`dispute_round_for`, mirroring `register_provider_for`'s already-accepted +delegation pattern, is the natural way to close that gap -- not attempted +here since it wasn't asked for. + ## Context ADR-011 accepted the Network Validator *protocol* (identity, stake, committee