diff --git a/pkg/accumulate/dn_search_test.go b/pkg/accumulate/dn_search_test.go index 0ab47f6..ca58a6a 100644 --- a/pkg/accumulate/dn_search_test.go +++ b/pkg/accumulate/dn_search_test.go @@ -201,6 +201,11 @@ func TestADNBlockIsSearchedThroughEveryAnchoredBlock(t *testing.T) { if len(txs) != 1 || !strings.EqualFold(txs[0].Hash, intentTx) || txs[0].BlockHeight != dnH || txs[0].AccountURL != "acc://user.acme/data" { t.Fatalf("found %+v; want the one intent, at DN height %d", txs, dnH) } + // RB4-F46: the BVN the transaction was written on, and its block there - the L1-L3 proof is built on it. Both + // were dropped, so every intent was proved on "acc://dn.acme" and no proof could be built. + if txs[0].ProofPartition != "bvn1" || txs[0].ProofBlockIndex != bvn1Block { + t.Fatalf("the intent was written on bvn1 block %d; it carries proof partition %q block %d", bvn1Block, txs[0].ProofPartition, txs[0].ProofBlockIndex) + } // The BVNs were read at their own block, never at the DN's height. for _, q := range f.blockQueries { if strings.HasPrefix(q, "acc://bvn") && strings.Contains(q, fmt.Sprintf("|%d|", dnH)) { @@ -270,3 +275,15 @@ func TestAnAnchorEntryThatIsNotAnAnchorIsAnError(t *testing.T) { t.Fatal("an anchor-pool entry that is not an anchor was skipped") } } + +// RB4-F46: a BVN partition name is read from its partition URL exactly; anything else is no BVN, never a default. +func TestTheBVNNameIsReadFromItsPartitionURL(t *testing.T) { + for in, want := range map[string]string{ + "acc://bvn-BVN1.acme": "bvn1", "acc://bvn-bvn0.acme": "bvn0", "acc://BVN-Apollo.acme": "apollo", + "acc://dn.acme": "", "acc://bvn-.acme": "", "acc://bvn-BVN1.acme/ledger": "", "": "", "bvn1": "", + } { + if got := BVNNameOf(in); got != want { + t.Errorf("BVNNameOf(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/accumulate/liteclient_adapter.go b/pkg/accumulate/liteclient_adapter.go index 7fab704..903c122 100644 --- a/pkg/accumulate/liteclient_adapter.go +++ b/pkg/accumulate/liteclient_adapter.go @@ -352,9 +352,28 @@ type CertenTransaction struct { Timestamp time.Time `json:"timestamp"` IntentData map[string]interface{} `json:"intent_data"` TransactionType string `json:"transaction_type"` - // Legacy fields for backward compatibility + // Partition is the partition whose block BlockHeight counts: the Directory Network block the intent was + // discovered in (it anchored the BVN block that carried it). Batch settlement keeps the two as one pair. Partition string `json:"partition,omitempty"` RawTx map[string]interface{} `json:"raw_tx,omitempty"` + // ProofPartition is the BVN the transaction was written on ("bvn1") and ProofBlockIndex its block there: an + // L1-L3 proof is built on that BVN (RB4-F46). Empty when the entry was not read from a BVN. + ProofPartition string `json:"proof_partition,omitempty"` + ProofBlockIndex int64 `json:"proof_block_index,omitempty"` +} + +// BVNNameOf reads a BVN's partition name from its partition URL, acc://bvn-.acme -> lower(), exactly. +// Anything else - the Directory Network, a ledger URL, a malformed name - is no BVN: "". +func BVNNameOf(partitionURL string) string { + u := strings.ToLower(strings.TrimSpace(partitionURL)) + if !strings.HasPrefix(u, "acc://bvn-") || !strings.HasSuffix(u, ".acme") { + return "" + } + id := strings.TrimSuffix(strings.TrimPrefix(u, "acc://bvn-"), ".acme") + if id == "" || strings.ContainsAny(id, "/.@") { + return "" + } + return id } // isCertenTransaction checks if a block entry is a CERTEN intent transaction @@ -635,6 +654,10 @@ func (l *LiteClientAdapter) parseCertenTransaction(entry BlockEntry, block *Mino RawTx: entry.Data, IntentData: make(map[string]interface{}), } + if bvn := BVNNameOf(entry.Partition); bvn != "" { + certenTx.ProofPartition = bvn + certenTx.ProofBlockIndex = entry.PartitionBlock + } // Try to extract intent data from the correct transaction structure intentData := l.extractIntentDataFromEntry(entry) @@ -1232,6 +1255,10 @@ type BlockEntry struct { Index int `json:"index"` Type string `json:"type"` Data map[string]interface{} `json:"data"` + // Partition and PartitionBlock are the partition URL the entry was read from and its block there + // (RB4-F46: a DN block's transactions are read from the BVN blocks it anchored, and which BVN was lost). + Partition string `json:"partition,omitempty"` + PartitionBlock int64 `json:"partition_block,omitempty"` } // parseMinorBlockRecord parses a single MinorBlockRecord from the v3 API response @@ -1308,8 +1335,10 @@ func (l *LiteClientAdapter) getBlockEntries(blockData map[string]interface{}, bl for entryIdx, entry := range allEntries { if entryMap, ok := entry.(map[string]interface{}); ok { blockEntry := BlockEntry{ - Index: entryIdx, - Data: entryMap, + Index: entryIdx, + Data: entryMap, + Partition: partition, + PartitionBlock: blockHeight, } // Extract entry type if available diff --git a/pkg/consensus/async_attestation.go b/pkg/consensus/async_attestation.go index 739ec2d..7b8279d 100644 --- a/pkg/consensus/async_attestation.go +++ b/pkg/consensus/async_attestation.go @@ -454,9 +454,10 @@ func (bv *BFTValidator) RunProofCycle( commitment, att.CertenIntent.AccountURL, att.CertenIntent.TransactionHash, - // The partition the transaction was discovered on - the chain's answer, and the one consensus - // built its proof on. It used to be "", leaving the cycle to recompute it (RB3-F89). - att.CertenIntent.Partition, + // The BVN the transaction was written on - the chain's answer, and the one consensus built its + // proof on (RB4-F46: this was the Directory Network partition discovery found it through). It used + // to be "", leaving the cycle to recompute it (RB3-F89). + att.CertenIntent.ProofPartition, ); err != nil { // The orchestrator records the refusal as the member's outcome where the member can be placed // (RB3-F103); this line is the validator's own record of it. @@ -636,9 +637,10 @@ func (bv *BFTValidator) recordFailedProofCycle( commitment, att.CertenIntent.AccountURL, att.CertenIntent.TransactionHash, - // The partition the transaction was discovered on - the chain's answer, and the one consensus - // built its proof on. It used to be "", leaving the cycle to recompute it (RB3-F89). - att.CertenIntent.Partition, + // The BVN the transaction was written on - the chain's answer, and the one consensus built its + // proof on (RB4-F46: this was the Directory Network partition discovery found it through). It used + // to be "", leaving the cycle to recompute it (RB3-F89). + att.CertenIntent.ProofPartition, ); err != nil { bv.logger.Printf("⚠️ [PROOF-CYCLE] could not record the failure of intent %s: %v — the "+ "intent is settled nowhere AND recorded nowhere, which needs operator attention", diff --git a/pkg/consensus/intent.go b/pkg/consensus/intent.go index 005db32..b5283de 100644 --- a/pkg/consensus/intent.go +++ b/pkg/consensus/intent.go @@ -71,11 +71,15 @@ type CertenIntent struct { TransactionHash string `json:"transactionHash"` AccountURL string `json:"accountUrl"` // Principal account URL (where TX lives): .../data OrganizationADI string `json:"organizationAdi"` // Organization ADI (for policy/routing): org ADI only - Partition string `json:"partition"` // BVN partition name (e.g., "bvn1") for L1-L3 proof generation - IntentData []byte `json:"intentData"` // Raw JSON blob - canonicalized later in commitment pipeline - CrossChainData []byte `json:"crossChainData"` // Raw JSON blob - canonicalized later in commitment pipeline - GovernanceData []byte `json:"governanceData"` // Raw JSON blob - canonicalized later in commitment pipeline - ReplayData []byte `json:"replayData"` // Raw JSON blob - canonicalized later in commitment pipeline + // Partition is the partition whose block the intent was discovered in (the Directory Network block that + // anchored it), paired with the discovery height for batch settlement's commit block. + Partition string `json:"partition"` + // ProofPartition is the BVN the intent was written on ("bvn1"): the L1-L3 proof is built on it (RB4-F46). + ProofPartition string `json:"proof_partition,omitempty"` + IntentData []byte `json:"intentData"` // Raw JSON blob - canonicalized later in commitment pipeline + CrossChainData []byte `json:"crossChainData"` // Raw JSON blob - canonicalized later in commitment pipeline + GovernanceData []byte `json:"governanceData"` // Raw JSON blob - canonicalized later in commitment pipeline + ReplayData []byte `json:"replayData"` // Raw JSON blob - canonicalized later in commitment pipeline // CRITICAL: Proof class determines execution routing per FIRST_PRINCIPLES 2.5 // On-demand vs on-cadence proofs are NEVER interchangeable diff --git a/pkg/execution/proof_partition_test.go b/pkg/execution/proof_partition_test.go index 32234ff..d754237 100644 --- a/pkg/execution/proof_partition_test.go +++ b/pkg/execution/proof_partition_test.go @@ -27,14 +27,15 @@ func TestAProofIsNeverBuiltOnAGuessedPartition(t *testing.T) { } } -// The proof cycle is started with the partition consensus used, not "". +// The proof cycle is started with the partition consensus used, not "": the BVN the transaction was written on +// (RB4-F46 - the intent Partition holds the Directory Network block discovery found it through). func TestTheProofCycleIsGivenTheDiscoveredPartition(t *testing.T) { raw, err := os.ReadFile("../consensus/async_attestation.go") if err != nil { t.Fatal(err) } src := string(raw) - if n := strings.Count(src, "att.CertenIntent.Partition,\n\t); err != nil {"); n != 2 { + if n := strings.Count(src, "att.CertenIntent.ProofPartition,\n\t); err != nil {"); n != 2 { t.Fatalf("%d of the 2 proof-cycle starts pass the discovered partition", n) } } diff --git a/pkg/intent/discovery.go b/pkg/intent/discovery.go index 62c5f61..7d39aef 100644 --- a/pkg/intent/discovery.go +++ b/pkg/intent/discovery.go @@ -1245,6 +1245,8 @@ func (id *IntentDiscovery) convertCertenTransactionToIntent(certenTx *accumulate if certenTx.Partition != "" { intent.Partition = strings.ToLower(certenTx.Partition) } + // The BVN it was written on, for its L1-L3 proof (RB4-F46). + intent.ProofPartition = certenTx.ProofPartition // The minor block's own time, read with the block that carried the transaction: consensus data. intent.BlockTime = certenTx.Timestamp @@ -1570,7 +1572,7 @@ func (id *IntentDiscovery) processIntent(intent *CertenIntent, blockHeight uint6 defer cancel() // REAL L1-L3 consensus-bound chained proof (requires txHash, partition, CometBFT binding). - realProofApplicable := id.proofGenerator.HasRealProofBuilder() && intent.TransactionHash != "" && intent.Partition != "" + realProofApplicable := id.proofGenerator.HasRealProofBuilder() && intent.TransactionHash != "" && intent.ProofPartition != "" if realProofApplicable { // on_demand (financial) gets in-line retry to absorb DN-anchoring latency / transient // DN-BVN RPC blips; on_cadence makes a single attempt and may fall back to a basic proof. @@ -1579,9 +1581,9 @@ func (id *IntentDiscovery) processIntent(intent *CertenIntent, blockHeight uint6 inlineAttempts = id.config.ChainedProofInlineRetries } id.logger.Printf("🔗 [REAL-PROOF] Generating L1-L4 chained proof for %s (txHash=%s, partition=%s, attempts=%d)", - intent.IntentID, intent.TransactionHash[:16]+"...", intent.Partition, inlineAttempts) + intent.IntentID, intent.TransactionHash[:16]+"...", intent.ProofPartition, inlineAttempts) - cp, perr := id.buildChainedCertenProof(ctx, accountURL, intent.TransactionHash, intent.Partition, intent.IntentID, inlineAttempts) + cp, perr := id.buildChainedCertenProof(ctx, accountURL, intent.TransactionHash, intent.ProofPartition, intent.IntentID, inlineAttempts) if perr != nil { id.logger.Printf("⚠️ [REAL-PROOF] L1-L4 chained proof unavailable for %s: %v", intent.IntentID, perr) } else { @@ -1610,9 +1612,9 @@ func (id *IntentDiscovery) processIntent(intent *CertenIntent, blockHeight uint6 // each must be backed by the same chained proof. if certenProof == nil { if !realProofApplicable { - return consensus.TargetChainFailed, fmt.Errorf("intent %s (proofClass=%s): %w (realBuilder=%v txHash=%q partition=%q)", + return consensus.TargetChainFailed, fmt.Errorf("intent %s (proofClass=%s): %w (realBuilder=%v txHash=%q proof partition=%q)", intent.IntentID, proofClass, errChainedProofTerminal, - id.proofGenerator.HasRealProofBuilder(), intent.TransactionHash, intent.Partition) + id.proofGenerator.HasRealProofBuilder(), intent.TransactionHash, intent.ProofPartition) } return consensus.TargetChainFailed, fmt.Errorf("intent %s (proofClass=%s): %w", intent.IntentID, proofClass, errChainedProofUnavailable) } @@ -1732,7 +1734,7 @@ func (id *IntentDiscovery) processMultiLegIntent(intent *CertenIntent, blockHeig defer cancel() // REAL L1-L3 consensus-bound chained proof (same fail-closed policy as single-leg). - realProofApplicable := id.proofGenerator.HasRealProofBuilder() && intent.TransactionHash != "" && intent.Partition != "" + realProofApplicable := id.proofGenerator.HasRealProofBuilder() && intent.TransactionHash != "" && intent.ProofPartition != "" if realProofApplicable { inlineAttempts := 1 if proofClass == "on_demand" { @@ -1740,7 +1742,7 @@ func (id *IntentDiscovery) processMultiLegIntent(intent *CertenIntent, blockHeig } id.logger.Printf("🔗 [MULTI-LEG] Generating L1-L3 chained proof for %s (attempts=%d)", intent.IntentID, inlineAttempts) - cp, perr := id.buildChainedCertenProof(ctx, accountURL, intent.TransactionHash, intent.Partition, intent.IntentID, inlineAttempts) + cp, perr := id.buildChainedCertenProof(ctx, accountURL, intent.TransactionHash, intent.ProofPartition, intent.IntentID, inlineAttempts) if perr != nil { id.logger.Printf("⚠️ [MULTI-LEG] L1-L3 chained proof unavailable for %s: %v", intent.IntentID, perr) } else { @@ -1754,9 +1756,9 @@ func (id *IntentDiscovery) processMultiLegIntent(intent *CertenIntent, blockHeig // idempotent so the requeue is replay-safe. if certenProof == nil { if !realProofApplicable { - return consensus.TargetChainFailed, fmt.Errorf("multi-leg intent %s (proofClass=%s): %w (realBuilder=%v txHash=%q partition=%q)", + return consensus.TargetChainFailed, fmt.Errorf("multi-leg intent %s (proofClass=%s): %w (realBuilder=%v txHash=%q proof partition=%q)", intent.IntentID, proofClass, errChainedProofTerminal, - id.proofGenerator.HasRealProofBuilder(), intent.TransactionHash, intent.Partition) + id.proofGenerator.HasRealProofBuilder(), intent.TransactionHash, intent.ProofPartition) } return consensus.TargetChainFailed, fmt.Errorf("multi-leg intent %s (proofClass=%s): %w", intent.IntentID, proofClass, errChainedProofUnavailable) } diff --git a/pkg/intent/proof_partition_test.go b/pkg/intent/proof_partition_test.go new file mode 100644 index 0000000..b963869 --- /dev/null +++ b/pkg/intent/proof_partition_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Certen Protocol + +package intent + +import ( + "log" + "os" + "strings" + "testing" + + "github.com/certen/independant-validator/pkg/accumulate" +) + +// RB4-F46: an intent is proved on the BVN it was written on. Discovery reads a Directory Network block through +// the BVN blocks it anchored and stamped every intent with "acc://dn.acme"; the L1-L3 proof refuses a non-BVN +// partition, so no intent could be proved (Phase C, 2026-09-29: "partition acc://dn.acme ... is not a BVN"). +// The discovery pair (the DN block and its height) stays the batch commit pair; the BVN rides beside it. +func TestAnIntentCarriesTheBVNItWasWrittenOn(t *testing.T) { + id := &IntentDiscovery{logger: log.New(os.Stderr, "", 0)} + ci, err := id.convertCertenTransactionToIntent(&accumulate.CertenTransaction{ + Hash: strings.Repeat("b2", 32), AccountURL: "acc://harbor.acme/data", BlockHeight: 9987981, + Partition: "acc://dn.acme", ProofPartition: "bvn1", ProofBlockIndex: 8270001, + IntentData: map[string]interface{}{ + "intentData": map[string]interface{}{"intent_id": "f46", "proof_class": "on_demand"}, + "crossChainData": map[string]interface{}{}, + "governanceData": map[string]interface{}{"organizationAdi": "acc://harbor.acme"}, + "replayData": map[string]interface{}{"expires_at": 1790600000}, + }, + }) + if err != nil { + t.Fatal(err) + } + if ci.ProofPartition != "bvn1" { + t.Fatalf("the intent is proved on %q; it was written on bvn1", ci.ProofPartition) + } + if ci.Partition != "acc://dn.acme" { + t.Fatalf("the discovery partition (the batch commit pair) became %q", ci.Partition) + } +}