Skip to content

Commit 3150087

Browse files
miner: support builder-proposed block with validator blind signing (#3691)
* miner: implement zero-simulate MEV * miner: implement validator-side BidBlock handling * fix: refine enable bidblock config * miner: switch BidBlock vs simBid to two-stage selection * fix: use builder timestamp when committing BidBlock * miner: split BidBlock verify and sign * miner: verify BidBlock GasFee after InsertChain * miner: add BidBlock permission admission * miner: revoke BidBlock permission on insert or gas-fee failure * miner: add BidBlock permission RPC * miner: expose BidBlock permission block number * miner: skip revoked cached BidBlock * miner: revoke malformed BidBlock winners * miner: use must-before cutoff for BidBlock * miner: preserve builder header for BidBlock commit * miner: retry BidBlock candidates after failure * parlia: add bindSign system tx mode * parlia: expose builder finalize path * parlia: clarify BidBlock system tx signing * miner: support builder header preparation * miner: preserve BidBlock execution header * miner: align BidBlock system tx shape * miner: reject disabled BidBlock submissions * docs: drop BidBlock draft notes * miner: align bid simulator constructor * parlia: clarify generated tx signing flag * parlia: align generated tx signing flag * ethclient: add BidBlock RPC helpers * parlia: align builder block time validator * miner: reject BidBlock with unknown parent * miner: align BidBlock selection gate * miner: refine BidBlock helper names * miner: address BidBlock review feedback * miner: use deterministic BidBlock timestamp * miner: address BidBlock review comments * miner: simplify BidBlock fork checks * miner: address BidBlock review nits * parlia: use mode for system tx processing * miner: prepare BidBlock before enqueue * miner: log BidBlock bid comparison * miner: simplify BidBlock candidate cache * miner: keep one best BidBlock * miner: return BidBlock cache feedback * miner: share double-sign check * miner: refine BidBlock validation flow * miner: simplify BidBlock selection state * miner: resolve develop rebase conflict * miner: simplify BidBlock task state * miner: validate BidBlock system tx ABI * miner: clean up BidBlock review fixes * miner: use fixed BidBlock system selectors * miner: align BidBlock assembly return values * miner: simplify BidBlock decoded payload * miner: simplify BidBlock selection comments * miner: add admin BidBlock permission control * miner: defer BidBlock revoke until post-insert * miner: simplify BidBlock permission checks * miner: simplify BidBlock permission manager * miner: simplify BidBlock preseal checks * miner: avoid retaining work for BidBlock * miner: simplify BidBlock validation flow * miner: derive BidBlock gas fee from deposit * miner: require BidBlock deposit tx * miner: simplify BidBlock tx shape * miner: simplify BidBlock config prep * miner: check BidBlock parent header * miner: organize BidBlock helpers * miner: verify BidBlock at admission * miner: gofmt BidBlock metrics * miner: preserve builder vanity in BidBlock extra-data * miner: dedupe BidBlock system-tx scan, expose BidBlockEnabled * consensus/parlia: check upperlimit for header.Time when blockTimeVerify (#26) * consensus/parlia: share Prepare core with BidBlock builder * consensus/parlia: unexport BidBlock system-tx helpers * consensus/parlia: simplify ExtractBidBlockDepositValue scan * miner: have validator own BidBlock extra-data * miner: address BidBlock review feedback * types: normalize empty BidBlock sidecars * miner: preserve empty BidBlock withdrawals body * miner: trim redundant BidBlock entry guards * miner: mini improve (#27) * miner: surface revoke error detail to builders * miner: use rolling BidBlock revoke window * miner: encode block builder info in requests hash * fix: infer local mev blocks from empty builder * docs: clarify block mev info versions * miner: harden BidBlock checks and track revoked builders * miner: validate BidBlock blob sidecars before seal * check bidblock blob eligibility * miner: check BidBlock bid tx gas price * miner: check BidBlock non-system gas price * miner: use BidBlock gas fee for price check * miner: address BidBlock review comments. * miner: improve BidBlock blob validation error. * miner: enforce validator BidBlock gas limit. * Revert "consensus/parlia: check upperlimit for header.Time when blockTimeVerify (#26)" This reverts commit a8a9685. * parlia: check upperlimit of block time * miner: simplify BidBlock revoke duration. * miner: add BidBlock RPC error codes. * mev: add bid block hash tracing * test: cover BidBlock consensus edge cases * miner: add BidBlock migration metrics * miner: gate BidBlock on Pasteur fork and default-enable * miner: fold Pasteur gate into BidBlock enablement --------- Co-authored-by: formless <213398294+allformless@users.noreply.github.com>
1 parent 02122b6 commit 3150087

34 files changed

Lines changed: 3032 additions & 149 deletions

cmd/jsutils/getchainstatus.js

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,58 @@ const builderMap = new Map([
290290
["0xA8caEc0D68a90Ac971EA1aDEFA1747447e1f9871", "blockroute"],
291291
]);
292292

293+
function getMappedAddressName(addressMap, address) {
294+
if (!address) return undefined;
295+
try {
296+
const normalized = ethers.getAddress(address);
297+
return addressMap.get(normalized) || addressMap.get(normalized.toLowerCase());
298+
} catch (_) {
299+
return addressMap.get(address);
300+
}
301+
}
302+
303+
async function getBlockMevInfo(blockNumber) {
304+
let rpcInfo;
305+
try {
306+
rpcInfo = await provider.send("eth_getBlockMevInfo", ["0x" + blockNumber.toString(16)]);
307+
if (rpcInfo) {
308+
if (!rpcInfo.builder) {
309+
return { ...rpcInfo, source: "local" };
310+
}
311+
const source = rpcInfo.version === "v2" ? "bidblock" : "bid";
312+
return { ...rpcInfo, source };
313+
}
314+
} catch (_) {
315+
// Older validators do not expose eth_getBlockMevInfo; fall back to the
316+
// pre-BEP-675 payment-tx heuristic below.
317+
}
318+
319+
const block = await provider.getBlock(blockNumber);
320+
if (!block) return rpcInfo || { blockNumber, source: "local" };
321+
322+
const txHashes = block.transactions.slice(-4);
323+
const txResults = await Promise.all(txHashes.map(txHash => provider.getTransaction(txHash)));
324+
for (const txData of txResults) {
325+
if (!txData || !txData.to || !getMappedAddressName(builderMap, txData.to)) continue;
326+
return {
327+
blockNumber: block.number,
328+
blockHash: block.hash,
329+
miner: block.miner,
330+
source: "bid",
331+
builder: txData.to,
332+
fallback: true,
333+
};
334+
}
335+
336+
return {
337+
...(rpcInfo || {}),
338+
blockNumber: rpcInfo ? (rpcInfo.blockNumber || block.number) : block.number,
339+
blockHash: rpcInfo ? (rpcInfo.blockHash || block.hash) : block.hash,
340+
miner: rpcInfo ? (rpcInfo.miner || block.miner) : block.miner,
341+
source: "local",
342+
};
343+
}
344+
293345
// 1.cmd: "GetMaxTxCountInBlockRange", usage:
294346
// node getchainstatus.js GetMaxTxCountInBlockRange --rpc https://bsc-testnet-dataseed.bnbchain.org \
295347
// --startNum 40000001 --endNum 40000005 \
@@ -856,6 +908,8 @@ async function getMevStatus() {
856908
local: 0,
857909
...Object.fromEntries([...new Set(builderMap.values())].map(builder => [builder, 0]))
858910
};
911+
// Per-type tallies for the MEV path breakdown (v1 = legacy SendBid, v2 = bidblock).
912+
let typeCounts = { mev_v1: 0, mev_v2: 0, local: 0 };
859913

860914
// Get the latest block number
861915
const latestBlock = await provider.getBlockNumber();
@@ -877,12 +931,18 @@ async function getMevStatus() {
877931
return;
878932
}
879933

880-
const blockPromises = [];
934+
const blockNumbers = [];
881935
for (let i = startBlock; i <= endBlock; i++) {
882-
blockPromises.push(provider.getBlock(i));
936+
blockNumbers.push(i);
883937
}
884938

885-
const blocks = await Promise.all(blockPromises);
939+
let mevInfos;
940+
try {
941+
mevInfos = await Promise.all(blockNumbers.map(getBlockMevInfo));
942+
} catch (err) {
943+
console.error("GetMevStatus failed:", err.shortMessage || err.message || err);
944+
return;
945+
}
886946

887947
// Calculate max lengths for alignment with default values
888948
let maxMinerLength = 10; // Default length
@@ -898,38 +958,39 @@ async function getMevStatus() {
898958
maxBuilderLength = Math.max(...builderLengths);
899959
}
900960

901-
for (const blockData of blocks) {
902-
const minerInfo = validatorMap.get(blockData.miner);
961+
for (const mevInfo of mevInfos) {
962+
const blockNumber = typeof mevInfo.blockNumber === "string"
963+
? BigInt(mevInfo.blockNumber).toString()
964+
: mevInfo.blockNumber.toString();
965+
const minerInfo = getMappedAddressName(validatorMap, mevInfo.miner);
903966
const miner = minerInfo ? minerInfo[0] : "Unknown";
904-
const transactions = blockData.transactions.slice(-4); // Last 4 transactions
905-
const txPromises = transactions.map(txHash => provider.getTransaction(txHash));
906-
907-
const txResults = await Promise.all(txPromises);
908-
let mevBlock = false;
909-
910-
for (const txData of txResults) {
911-
if (builderMap.has(txData.to)) {
912-
const builder = builderMap.get(txData.to);
913-
counts[builder]++;
914-
915-
mevBlock = true;
916-
console.log(
917-
`blockNum: ${blockData.number.toString().padStart(8)} ` +
918-
`miner: ${miner.padEnd(maxMinerLength)} ` +
919-
`builder: (${builder.padEnd(maxBuilderLength)}) ${txData.to}`
920-
);
921-
break;
922-
}
923-
}
924967

925-
if (!mevBlock) {
926-
counts.local++;
968+
if (mevInfo.source !== "local") {
969+
const builderName = getMappedAddressName(builderMap, mevInfo.builder);
970+
const friendlyName = builderName || mevInfo.builder;
971+
const bucket = builderName || mevInfo.builder;
972+
// v2 = bidblock (tagged), everything else on the MEV path = v1
973+
// (tagged legacy bid, or heuristic-detected payBidTx which is v1-only).
974+
const typeLabel = (mevInfo.source === "bidblock" || mevInfo.version === "v2") ? "mev_v2" : "mev_v1";
975+
counts[bucket] = (counts[bucket] || 0) + 1;
976+
typeCounts[typeLabel]++;
927977
console.log(
928-
`blockNum: ${blockData.number.toString().padStart(8)} ` +
978+
`blockNum: ${blockNumber.padStart(8)} ` +
979+
`type: ${typeLabel.padEnd(6)} ` +
929980
`miner: ${miner.padEnd(maxMinerLength)} ` +
930-
`builder: local`
981+
`builder: (${friendlyName.padEnd(maxBuilderLength)}) ${mevInfo.builder}`
931982
);
983+
continue;
932984
}
985+
986+
counts.local++;
987+
typeCounts.local++;
988+
console.log(
989+
`blockNum: ${blockNumber.padStart(8)} ` +
990+
`type: ${"local".padEnd(6)} ` +
991+
`miner: ${miner.padEnd(maxMinerLength)} ` +
992+
`builder: local`
993+
);
933994
}
934995

935996
const total = endBlock - startBlock + 1;
@@ -947,6 +1008,17 @@ async function getMevStatus() {
9471008
const ratio = (value * 100 / total).toFixed(2);
9481009
console.log(`${key.padEnd(maxBuilderLength)}: ${value.toString().padStart(3)} blocks (${ratio}%)`);
9491010
});
1011+
1012+
// MEV path breakdown: v1 (legacy SendBid) vs v2 (bidblock), as a share of MEV blocks.
1013+
const mevTotal = typeCounts.mev_v1 + typeCounts.mev_v2;
1014+
console.log("\nMEV Path Distribution:");
1015+
console.log(`MEV blocks: ${mevTotal} (${(mevTotal * 100 / total).toFixed(2)}% of total)`);
1016+
if (mevTotal > 0) {
1017+
const v1Ratio = (typeCounts.mev_v1 * 100 / mevTotal).toFixed(2);
1018+
const v2Ratio = (typeCounts.mev_v2 * 100 / mevTotal).toFixed(2);
1019+
console.log(`${"mev_v1".padEnd(8)}: ${typeCounts.mev_v1.toString().padStart(3)} blocks (${v1Ratio}% of MEV)`);
1020+
console.log(`${"mev_v2".padEnd(8)}: ${typeCounts.mev_v2.toString().padStart(3)} blocks (${v2Ratio}% of MEV)`);
1021+
}
9501022
}
9511023

9521024
// 11.cmd: "getLargeTxs", usage:

consensus/parlia/bid_block.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package parlia
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"math/big"
8+
9+
"github.com/ethereum/go-ethereum/accounts"
10+
"github.com/ethereum/go-ethereum/common"
11+
"github.com/ethereum/go-ethereum/consensus"
12+
"github.com/ethereum/go-ethereum/core/state"
13+
"github.com/ethereum/go-ethereum/core/systemcontracts"
14+
"github.com/ethereum/go-ethereum/core/tracing"
15+
"github.com/ethereum/go-ethereum/core/types"
16+
)
17+
18+
var signableSystemTxSelectors = map[string][4]byte{
19+
"deposit": {0xf3, 0x40, 0xfa, 0x01},
20+
"distributeFinalityReward": {0x30, 0x0c, 0x35, 0x67},
21+
"updateValidatorSetV2": {0x1e, 0x4c, 0x15, 0x24},
22+
}
23+
24+
type expectedSystemTxEntry struct {
25+
method string
26+
selector [4]byte
27+
}
28+
29+
// PrepareForBidBlock is Prepare with Coinbase set to the in-turn validator instead of p.val.
30+
func (p *Parlia) PrepareForBidBlock(chain consensus.ChainHeaderReader, header *types.Header) error {
31+
// Coinbase must be set before prepare(): backOffTime and calcDifficulty depend on it.
32+
number := header.Number.Uint64()
33+
snap, err := p.snapshot(chain, number-1, header.ParentHash, nil)
34+
if err != nil {
35+
return err
36+
}
37+
header.Coinbase = snap.inturnValidator()
38+
return p.prepare(chain, header)
39+
}
40+
41+
// FinalizeAndAssembleBidBlock assembles a BidBlock with unsigned system txs.
42+
func (p *Parlia) FinalizeAndAssembleBidBlock(chain consensus.ChainHeaderReader, header *types.Header, state *state.StateDB,
43+
body *types.Body, receipts []*types.Receipt, tracer *tracing.Hooks) (*types.Block, []*types.Receipt, error) {
44+
block, receipts, err := p.finalizeAndAssemble(chain, header, state, body, receipts, tracer, systemTxPacking)
45+
if err != nil {
46+
return nil, nil, err
47+
}
48+
return block, receipts, nil
49+
}
50+
51+
// SignSystemTx signs a BidBlock system tx with the validator key.
52+
func (p *Parlia) SignSystemTx(tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
53+
p.lock.RLock()
54+
defer p.lock.RUnlock()
55+
if p.signTxFn == nil {
56+
return nil, errors.New("signTxFn not set")
57+
}
58+
return p.signTxFn(accounts.Account{Address: p.val}, tx, chainID)
59+
}
60+
61+
// isUnsignedSystemTxCandidate reports whether tx looks like an unsigned
62+
// BidBlock system tx. It does not recover the sender.
63+
func (p *Parlia) isUnsignedSystemTxCandidate(tx *types.Transaction) bool {
64+
if tx == nil || tx.To() == nil || !isToSystemContract(*tx.To()) {
65+
return false
66+
}
67+
if tx.GasPrice() == nil || tx.GasPrice().Sign() != 0 {
68+
return false
69+
}
70+
v, r, s := tx.RawSignatureValues()
71+
return isZeroSig(v, r, s)
72+
}
73+
74+
// isSignableSystemTx reports whether tx can be bind-signed for BidBlock.
75+
func (p *Parlia) isSignableSystemTx(tx *types.Transaction) bool {
76+
if !p.isUnsignedSystemTxCandidate(tx) {
77+
return false
78+
}
79+
if *tx.To() != common.HexToAddress(systemcontracts.ValidatorContract) {
80+
return false
81+
}
82+
return p.hasSignableSelector(tx.Data())
83+
}
84+
85+
// expectedSystemTxShape returns the expected trailing system-tx order for accepted BidBlocks:
86+
//
87+
// deposit -> distributeFinalityReward (cond.) -> updateValidatorSetV2 (cond.)
88+
//
89+
// Precondition: BidBlock admission has already enforced a non-zero deposit value.
90+
func (p *Parlia) expectedSystemTxShape(header, parent *types.Header) []expectedSystemTxEntry {
91+
shape := make([]expectedSystemTxEntry, 0, 3)
92+
93+
shape = append(shape, expectedSystemTxEntry{
94+
method: "deposit",
95+
selector: p.selectorFor("deposit"),
96+
})
97+
98+
if header.Number.Uint64()%finalityRewardInterval == 0 {
99+
shape = append(shape, expectedSystemTxEntry{
100+
method: "distributeFinalityReward",
101+
selector: p.selectorFor("distributeFinalityReward"),
102+
})
103+
}
104+
105+
if isBreatheBlock(parent.Time, header.Time) {
106+
shape = append(shape, expectedSystemTxEntry{
107+
method: "updateValidatorSetV2",
108+
selector: p.selectorFor("updateValidatorSetV2"),
109+
})
110+
}
111+
112+
return shape
113+
}
114+
115+
func (p *Parlia) verifySystemTxShape(txs []*types.Transaction, shape []expectedSystemTxEntry) error {
116+
if len(txs) < len(shape) {
117+
return fmt.Errorf("missing required system tx %q", shape[len(txs)].method)
118+
}
119+
if len(txs) > len(shape) {
120+
return fmt.Errorf("unexpected extra system tx at position %d (selector 0x%x)",
121+
len(shape), txSelector(txs[len(shape)]))
122+
}
123+
for i, exp := range shape {
124+
if !bytes.HasPrefix(txs[i].Data(), exp.selector[:]) {
125+
return fmt.Errorf("expected system tx %q at position %d, got selector 0x%x",
126+
exp.method, i, txSelector(txs[i]))
127+
}
128+
}
129+
return nil
130+
}
131+
132+
// ExtractBidBlockDepositValue locates the trailing unsigned system-tx region and
133+
// returns its start index along with the value of the deposit tx (zero if absent).
134+
func (p *Parlia) ExtractBidBlockDepositValue(txs []*types.Transaction) (int, *big.Int) {
135+
systemTxStart := len(txs)
136+
for i := len(txs) - 1; i >= 0; i-- {
137+
if !p.isUnsignedSystemTxCandidate(txs[i]) {
138+
break
139+
}
140+
systemTxStart = i
141+
}
142+
// Deposit is the first trailing unsigned system tx (see expectedSystemTxShape).
143+
// systemTxStart == 0 means there are no preceding user txs to collect fees from,
144+
// which is invalid by design — return zero GasFee so admission rejects it.
145+
if systemTxStart > 0 && systemTxStart < len(txs) {
146+
depositSel := p.selectorFor("deposit")
147+
if bytes.HasPrefix(txs[systemTxStart].Data(), depositSel[:]) {
148+
return systemTxStart, new(big.Int).Set(txs[systemTxStart].Value())
149+
}
150+
}
151+
return systemTxStart, new(big.Int)
152+
}
153+
154+
// VerifyBidBlockSystemTxs validates the trailing unsigned system-tx region starting at systemTxStart.
155+
//
156+
// Stage 1 — each trailing unsigned tx must be on the BEP-675 signable whitelist.
157+
// Stage 2 — selectors & order must match expectedSystemTxShape for this header.
158+
func (p *Parlia) VerifyBidBlockSystemTxs(decoded *types.DecodedBidBlock, parent *types.Header, systemTxStart int) error {
159+
for i := systemTxStart; i < len(decoded.Txs); i++ {
160+
if !p.isSignableSystemTx(decoded.Txs[i]) {
161+
toAddr := "<nil>"
162+
if decoded.Txs[i].To() != nil {
163+
toAddr = decoded.Txs[i].To().Hex()
164+
}
165+
return fmt.Errorf("unsigned system tx at position %d (to=%s) is not on the signable whitelist", i, toAddr)
166+
}
167+
}
168+
shape := p.expectedSystemTxShape(decoded.Header, parent)
169+
return p.verifySystemTxShape(decoded.Txs[systemTxStart:], shape)
170+
}
171+
172+
func (p *Parlia) hasSignableSelector(data []byte) bool {
173+
if len(data) < 4 {
174+
return false
175+
}
176+
selector := data[:4]
177+
for _, methodSelector := range signableSystemTxSelectors {
178+
if bytes.Equal(selector, methodSelector[:]) {
179+
return true
180+
}
181+
}
182+
return false
183+
}
184+
185+
func (p *Parlia) selectorFor(methodName string) [4]byte {
186+
selector, ok := signableSystemTxSelectors[methodName]
187+
if !ok {
188+
panic(fmt.Sprintf("missing fixed system tx selector %s", methodName))
189+
}
190+
return selector
191+
}
192+
193+
func (p *Parlia) BlockTimeUpperCheck(chain consensus.ChainHeaderReader, header *types.Header) error {
194+
number := header.Number.Uint64()
195+
snap, err := p.snapshot(chain, number-1, header.ParentHash, nil)
196+
if err != nil {
197+
return err
198+
}
199+
200+
parent := chain.GetHeader(header.ParentHash, number-1)
201+
if parent == nil {
202+
return consensus.ErrUnknownAncestor
203+
}
204+
205+
maxAllowed := p.blockTimeForRamanujanFork(snap, header, parent)
206+
if header.MilliTimestamp() > maxAllowed {
207+
return fmt.Errorf("BidBlock time too far in future: headerTime=%d, maxAllowed=%d",
208+
header.MilliTimestamp(), maxAllowed)
209+
}
210+
return nil
211+
}
212+
213+
func txSelector(tx *types.Transaction) []byte {
214+
data := tx.Data()
215+
if len(data) < 4 {
216+
return data
217+
}
218+
return data[:4]
219+
}
220+
221+
func isZeroSig(v, r, s *big.Int) bool {
222+
isZero := func(x *big.Int) bool { return x == nil || x.Sign() == 0 }
223+
return isZero(v) && isZero(r) && isZero(s)
224+
}

0 commit comments

Comments
 (0)