ledger settlement¶
本檔是比賽結果、經濟結算結構與簽章交換協議的實作 authority;總入口見 ledger.md。
5. 比賽結果與經濟結算結構¶
interface MatchResultEvent extends BaseEvent {
type: "match-result";
matchId: string;
gridProof: StartGridProof; // 完整 commitment/reveal proof,只在外層保存一次
ranking: PeerId[]; // 總名次(N 回合積分加總 + tiebreak;驗算者重算比對,見 算式表 §20.1)
rounds: RoundResult[]; // 逐回合權威結果(積分 / 總名次由此 derive、不另存)
roundAnchors: Array<RaceConsensusAnchorEvent | null>; // inline 自包含證書;null=該場不得鑄幣
loadouts: Readonly<Record<PeerId, MatchParticipantLoadout>>;
loadoutSignatures: Readonly<Record<PeerId, Signature>>; // 逐 peer matchId-bound loadout 簽章=參與證明(收件⓪-loadout 驗、applier 只罰/只評有此者;擋塞人投毒)
matchRules: MatchRules;
disconnects: DisconnectInfo[];
startedAt: Timestamp;
finishedAt: Timestamp;
clientVersions: Readonly<Record<PeerId, ClientVersionInfo>>;
signatures: MatchResultSignature[]; // reason 無關,一律⌊original roster/2⌋+1,覆蓋 disconnects
}
interface RaceConsensusAnchorEvent extends BaseEvent {
type: "race-consensus-anchor";
matchId: string;
gridContextDigest: string; // 綁外層 gridProof context
gridSeed: string; // 綁外層 gridProof seed
roundIndex: number;
frame: number;
checksum: string;
presentPeers: PeerId[];
signatures: MatchResultSignature[];
}
interface RaceLeaveEvent extends BaseEvent {
type: "race-leave";
matchId: string;
roundIndex: number;
reason: "voluntary"; // 本人單簽,只作用 peerId 本人;不得縮小 MatchResult quorum
}
interface RaceAbortEvidenceEvent extends BaseEvent {
type: "race-abort-evidence";
matchId: string;
reason: "settlement-no-quorum" | "consensus-invalid" | "session-error";
frameSummaries: Array<{
roundIndex: number;
frame: number;
checksum: string;
}>;
receivedMessageDigests: Array<{
sender: PeerId;
kind: "leave" | "settlement-reject" | "settlement-cancel" | "checksum";
digest: string;
}>;
observations: Array<{
roundIndex: number;
frame: number;
kind: "peer-left" | "no-quorum" | "desync" | "session-error";
subject?: PeerId;
}>;
anchorConflicts: RaceAnchorConflictEvidence[]; // 至多 1;同 round 互斥 anchor 與交集 signer 證據
// 標準單簽、陣列各至多 16 且 canonical 排序;零業務 reducer
}
interface RoundResult {
roundIndex: number; // 0-based
trackRef: PartRef;
lapCount: number; // 該回合場地(可與他回合同軌)+ 圈數(linear/open = 1)
disallowChip?: boolean; // 該回合實際採用的晶片規則;缺省 = false
endReason: "completed" | "eliminated" | "duration-limit"; // current shape 必寫並納入多簽
terminalFrame: number; // 該回合最後 canonical frame;inline anchor tail 驗證基準
ranking: PeerId[]; // 該回合名次(完賽時間 → 圈數 → PeerId)
finishTimes: Readonly<Record<PeerId, number>>; // ms;DNF = 該回合 durationLimitSec × 1000
destructionCounts: Readonly<Record<PeerId, number>>; // 完整 roster;直接致毀零件件數,0 亦必填
}
interface MatchEconomySettlement { // DerivedState/MatchRecord 內部結果,不是 LedgerEvent wire schema
mintEligible: boolean; // 場級資格 = finisherCount ≥ 3、combo 未達 K=5、每回合 inline anchor 皆非 null;false → prizes / royalties 全空、usage 不記
monthFactorX100: number; // 月軟曲線係數 ×100(fold 由事件位置前態計算)
comboHash: string; // 24h K=5 組合 hash(完賽者群組、per-match)
matchPrizes: ReadonlyArray<MatchPrizeShare>; // 依總名次;受月軟曲線 / per-player 上限(經濟系統 §6.1b)
royalties: ReadonlyArray<RoyaltyShare>; // N 回合所有 track + car UGC 累加去重;70/20/10、24h 同 UGC 去重
}
interface MatchPrizeShare {
recipient: PeerId;
amount: bigint;
rank: number;
} // rank = 完賽者排名(總名次序、forfeit 不佔位)
interface RoyaltyShare {
recipient: PeerId;
triggerPlayer: PeerId; // 實際觸發本筆分潤的完賽者;fold 依此更新 (sourceCid, player) 去重錨
amount: bigint;
sourceCid: CID;
forkLineageDepth: 0 | 1 | 2;
}
interface MatchParticipantLoadout {
peerId: PeerId;
carRotation: VehicleLoadout[];
} // 長度 = MATCH_ROUND_COUNT(一車 / 回合、可重複用同台)
interface DisconnectInfo {
peerId: PeerId;
disconnectedAt: Timestamp;
reason: "voluntary" | "network";
}
〔LEDGER-R-051〕 可簽章欄位一律 plain Record / array——dag-cbor 對 JS Map 往返不對稱(decode 回 plain object,重編位元組即與簽章時不一致)。
〔ECON-R-048〕 完賽者簽章前只驗自包含 match facts(rounds、loadouts、grid proof、inline anchors 與 roster);事件不含 payout 或 frontier。金額由每個 peer 在 canonical fold 的同一事件位置,依前態唯一計算,詳見 economy.md §4。
collectAllUgcUsedInMatch(純函數)¶
下游(economy / moderation / ugc-rating)需「該場比賽 N 回合用到的所有非公版 UGC CID」:
function collectAllUgcUsedInMatch(
match: Pick<MatchResultEvent, "rounds" | "loadouts">,
): ReadonlySet<CID> {
const cids = new Set<CID>();
const addIfUgc = (ref: string) => {
if (!ref.startsWith("builtin:") && !ref.startsWith("local:"))
cids.add(ref as CID);
};
for (const r of match.rounds) addIfUgc(r.trackRef); // N 回合各自場地
for (const loadout of match.loadouts.values())
for (const car of loadout.carRotation)
// N 台輪換車
for (const ref of collectPartRefsFromLoadout(car))
// chassis/body/motor/battery/chip/tires/rollers/weapon
addIfUgc(ref);
return cids;
}
- 〔LEDGER-R-053〕
builtin:*過濾 → 公版不算 UGC 使用、不觸發 royalty;local:*過濾 → defense in depth(不該出現在線上賽)。 - Set 去重 → 同一軌 / 同一台車跨多回合重複用,royalty 只算一次(per-match 去重);match 含越多回合 → 自然用到越多不同 UGC → royalty 累加。
- 純函數 → economy / moderation / ugc-rating 各自呼叫結果一致。
- loadout 欄位用
chip。
比賽結算簽章交換協議(實作)¶
〔LEDGER-R-052〕 完賽 → 主 peer 組裝自包含 match-facts candidate → 完賽者各自核對 → ⌊N/2⌋+1 簽章 → 寫 ledger。candidate 不含 payout 或經濟 frontier;結算計算只在 canonical fold 發生(economy.md §4)。
// 透過既有 RaceMesh ordered control DataChannel 的 app-control bus 在參賽者間交換
// (DAG-CBOR、固定 roster 驗來源);不新增 GossipSub topic,也不寫 ledger,最終 MatchResultEvent 才寫
interface SettlementProposalMessage {
type: "settlement-proposal";
matchId: string;
candidate: Omit<MatchResultEvent, "signatures">;
proposerSignature: MatchResultSignature; // 主 peer 自簽(算進 quorum)
}
interface SettlementSignatureMessage {
type: "settlement-signature";
matchId: string;
candidateHash: string;
signature: MatchResultSignature;
}
interface SettlementRejectMessage {
type: "settlement-reject";
matchId: string;
candidateHash: string;
reason: string;
rejecter: PeerId;
transient?: boolean;
}
interface SettlementTakeoverMessage {
// 主 peer 中斷時任一完賽者接手
type: "settlement-takeover";
matchId: string;
newProposer: PeerId;
reusedCandidateHash: string;
collectedSignatures: ReadonlyArray<MatchResultSignature>; // 接手前已收集簽章(接收者逐一驗證)
proposedAt: Timestamp; // takeover 等候窗起點
}
interface SettlementCancelMessage {
// 收集端裁定不可能湊滿(決定性拒簽者過多)=主動終局:全員即刻收攤、免等滿窗
type: "settlement-cancel";
matchId: string;
candidateHash: string;
reason: string;
canceller: PeerId;
}
// candidateHash = signingDigest(candidate)=sha256(serializeForSigning(candidate));所有訊息引用同一 hash 確保版本對齊
// 主 peer:只建比賽事實 candidate + 自簽
async function buildMatchResultCandidate(
ctx: MatchContext,
deps: SettlementSignerDeps,
): Promise<{
candidate: Omit<MatchResultEvent, "signatures">;
proposerSignature: MatchResultSignature;
}> {
const now = deps.now();
const base = {
type: "match-result" as const,
matchId: ctx.matchId,
gridProof: ctx.gridProof,
ranking: deriveTotalRanking(ctx.rounds),
rounds: ctx.rounds,
roundAnchors: ctx.roundAnchors,
loadouts: mapToPlainRecord(ctx.submittedLoadouts), // 可簽章欄位=plain Record(上方鐵則)
loadoutSignatures: mapToPlainRecord(ctx.loadoutSignatures),
matchRules: ctx.matchRules,
disconnects: ctx.disconnects,
startedAt: ctx.startedAt,
finishedAt: now,
timestamp: now,
clientVersions: mapToPlainRecord(ctx.clientVersions),
peerId: deps.getPeerId(),
signature: new Uint8Array(0),
};
const candidate = base;
const sig = await deps.sign(ledgerSigningDigest(deps.ledgerAddress, candidate));
return {
candidate,
proposerSignature: { signer: deps.getPeerId(), sig },
};
}
// 完賽者:驗證提案者、總名次與本機 match facts 後簽章
async function reviewAndSignMatchResult(
msg: SettlementProposalMessage,
deps: SettlementSignerDeps,
localChecks?: MatchFactChecks,
): Promise<Result<MatchResultSignature>> {
const me = deps.getPeerId();
const { candidate, proposerSignature } = msg;
if (
!candidate.ranking.includes(me) ||
candidate.disconnects.some((d) => d.peerId === me)
)
return error("非完賽者不可簽章"); // 非 forfeit 完賽者才可簽(收件 gate ① 同規則)
if (proposerSignature.signer !== candidate.peerId)
return error("proposerSignature.signer 與 candidate.peerId 不一致");
if (
!(await deps.verify(
ledgerSigningDigest(deps.ledgerAddress, candidate),
proposerSignature.sig,
proposerSignature.signer,
))
)
return error("proposerSignature 無效");
// 多回合 consensus-critical 檢核(防主 peer 偽造):
// ① rounds[] 各回合 ranking / finishTimes / destructionCounts 與本機賽內觀測一致(每回合 deterministic 物理、各 peer 自有結果)
// ② inline anchors 與本機收集證書一致
// ③ loadouts / matchRules 與賽前提交一致
// ② 總名次防偽:candidate.ranking 須等於由 rounds[] 重算的總名次(算式表 §20.1 `deriveTotalRanking`)
const recomputed = deriveTotalRanking(candidate.rounds);
if (
candidate.ranking.length !== recomputed.length ||
candidate.ranking.some((p, i) => p !== recomputed[i])
)
return error("總名次與回合積分重算不符");
if (localChecks?.verifyRounds && !localChecks.verifyRounds(candidate.rounds))
return error("rounds 與本機觀測不符");
if (localChecks?.verifyRoundAnchors && !localChecks.verifyRoundAnchors(candidate.roundAnchors))
return error("roundAnchors 與本機證書不符");
if (localChecks?.verifyLoadouts && !localChecks.verifyLoadouts(candidate.loadouts))
return error("loadouts 與賽前提交不符");
return ok({
signer: me,
sig: await deps.sign(ledgerSigningDigest(deps.ledgerAddress, candidate)),
});
}
MatchContext = { matchId; config: MatchConfig; submittedLoadouts: ReadonlyMap<PeerId, MatchParticipantLoadout>; loadoutSignatures: ReadonlyMap<PeerId, Signature>; rounds: RoundResult[]; startedAt; disconnects }(賽前 loadout 收集見 matchmaking.md §5;loadoutSignatures=loadout 交換時收集的 matchId-bound 參與證明、隨結算入事件;rounds 隨每回合完成累積)。各回合 trackRef / lapCount 從 config.rounds[i] 取、materialize 進 RoundResult,不在事件頂層重複。