mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
304 lines
11 KiB
Go
304 lines
11 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// bridge.go — the dispatch + ABI decode for the two A-Chain inference bridge ops.
|
|
// This is the precompile's Run() body. It owns: selector routing, gas, calldata
|
|
// hardening, sourcing the C tx identity (caller / tx hash / chain ids / call index),
|
|
// constructing the C-state store, and routing to Pattern A (submit) or Pattern B
|
|
// (verify). It NEVER calls into aivm.
|
|
//
|
|
// REVERT DISCIPLINE (geth-canonical, mirror of the inference + dex paths): an
|
|
// out-of-gas condition returns (nil, 0, err) — charged gas consumed; a decode /
|
|
// state / client error returns (nil, remainingGas, err) — only the gas already
|
|
// charged for this call is consumed, the leftover is returned. There is NO
|
|
// all-gas-burn beyond the charged amount.
|
|
|
|
package aivmbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/precompile/contract"
|
|
)
|
|
|
|
// Bridge method selectors (first 4 bytes). Chosen in the AI range, disjoint from the
|
|
// inference precompile's SelectorGenerate (0x01000000) — this is a SEPARATE precompile
|
|
// at its own address, but we keep the selector space tidy and self-documenting.
|
|
const (
|
|
// submitInferenceIntent(bytes32 modelSpecHash, bytes32 promptHash,
|
|
// uint16 n, uint16 threshold, uint256 fee, bytes32 routing)
|
|
// -> bytes32 intentID
|
|
SelectorSubmitInferenceIntent uint32 = 0x10000000
|
|
|
|
// verifyInferenceReceipt(bytes receiptBytes, bytes proofBytes)
|
|
// -> (bytes32 intentID, bytes32 canonicalOutputHash, uint8 status)
|
|
SelectorVerifyInferenceReceipt uint32 = 0x11000000
|
|
|
|
// verifyComputeProof(bytes openingWire) -> bytes32 verdict
|
|
// the NATIVE proof-of-inference check: decode a beacon-selected matmul opening and return
|
|
// (included | freivaldsOK<<1) so the gate can tell a genuine opening from a fraud. See
|
|
// computeproof.go. The opening wire is crypto/poi.EncodeOpening.
|
|
SelectorVerifyComputeProof uint32 = 0x12000000
|
|
)
|
|
|
|
// MaxFanout bounds N so a single intent cannot request an unbounded provider fan-out
|
|
// (defense-in-depth on calldata; the A settlement layer also bounds it).
|
|
const MaxFanout uint16 = 256
|
|
|
|
// submitIntentArgsLen is the fixed ABI frame for SubmitInferenceIntent (after the
|
|
// 4-byte selector): 6 words = 192 bytes.
|
|
//
|
|
// [0:32] modelSpecHash (bytes32)
|
|
// [32:64] promptHash (bytes32)
|
|
// [64:96] n (uint16, right-aligned)
|
|
// [96:128] threshold (uint16, right-aligned)
|
|
// [128:160] fee (uint256)
|
|
// [160:192] routing (bytes32, opaque routing hint — recorded off-state, in ZAP)
|
|
const submitIntentArgsLen = 6 * 32
|
|
|
|
// runBridge is the precompile Run() body. Selector-routed; OOG-checked per op.
|
|
func runBridge(
|
|
accessibleState contract.AccessibleState,
|
|
caller common.Address,
|
|
input []byte,
|
|
suppliedGas uint64,
|
|
readOnly bool,
|
|
) (ret []byte, remainingGas uint64, err error) {
|
|
if len(input) < 4 {
|
|
return nil, suppliedGas, ErrInputTooShort
|
|
}
|
|
selector := binary.BigEndian.Uint32(input[:4])
|
|
args := input[4:]
|
|
|
|
switch selector {
|
|
case SelectorSubmitInferenceIntent:
|
|
return submitIntent(accessibleState, caller, args, suppliedGas, readOnly)
|
|
case SelectorVerifyInferenceReceipt:
|
|
return verifyReceipt(accessibleState, args, suppliedGas, readOnly)
|
|
case SelectorVerifyComputeProof:
|
|
// pure verification — no state, so readOnly is fine.
|
|
return verifyComputeProof(args, suppliedGas)
|
|
default:
|
|
return nil, suppliedGas, fmt.Errorf("aivmbridge: unknown selector %#x", selector)
|
|
}
|
|
}
|
|
|
|
// --- Pattern A: SubmitInferenceIntent -----------------------------------------
|
|
|
|
func submitIntent(
|
|
accessibleState contract.AccessibleState,
|
|
caller common.Address,
|
|
args []byte,
|
|
suppliedGas uint64,
|
|
readOnly bool,
|
|
) ([]byte, uint64, error) {
|
|
// Gas first (OOG burns charged gas).
|
|
if suppliedGas < GasSubmitInferenceIntent {
|
|
return nil, 0, fmt.Errorf("aivmbridge: out of gas: need %d have %d", GasSubmitInferenceIntent, suppliedGas)
|
|
}
|
|
remainingGas := suppliedGas - GasSubmitInferenceIntent
|
|
|
|
// A write op cannot run in a static call.
|
|
if readOnly {
|
|
return nil, remainingGas, ErrReadOnly
|
|
}
|
|
|
|
// Calldata hardening: EXACT frame length (reject short AND oversized/trailing junk).
|
|
if len(args) < submitIntentArgsLen {
|
|
return nil, remainingGas, ErrInputTooShort
|
|
}
|
|
if len(args) > submitIntentArgsLen {
|
|
return nil, remainingGas, ErrInputOversized
|
|
}
|
|
|
|
var in InferenceIntent
|
|
in.Caller = caller // bound from the C tx caller (identity binding)
|
|
copy(in.ModelSpecHash[:], args[0:32])
|
|
copy(in.ModelPromptHash[:], args[32:64])
|
|
|
|
n, err := readUint16Word(args[64:96])
|
|
if err != nil {
|
|
return nil, remainingGas, err
|
|
}
|
|
thr, err := readUint16Word(args[96:128])
|
|
if err != nil {
|
|
return nil, remainingGas, err
|
|
}
|
|
in.N = n
|
|
in.Threshold = thr
|
|
copy(in.Fee[:], args[128:160])
|
|
// args[160:192] is the opaque routing hint — it is NOT consensus state and NOT in
|
|
// the intent_id preimage; it travels only in the (transport-only) ZAP nudge. The
|
|
// committed C outbox is the source of truth, so a routing hint can never affect the
|
|
// state root. We read it for the notifier but do not store it on-state.
|
|
var routing [32]byte
|
|
copy(routing[:], args[160:192])
|
|
|
|
// Commitments must be non-zero (an all-zero commitment would alias an unbound
|
|
// request across callers/models).
|
|
if isZero32(in.ModelSpecHash) {
|
|
return nil, remainingGas, ErrZeroModelSpec
|
|
}
|
|
if isZero32(in.ModelPromptHash) {
|
|
return nil, remainingGas, ErrZeroPrompt
|
|
}
|
|
// Fan-out hardening: N in [1, MaxFanout], threshold in [1, N]. A malformed fan-out
|
|
// reverts BEFORE any intent is recorded.
|
|
if in.N == 0 || in.N > MaxFanout {
|
|
return nil, remainingGas, ErrBadFanout
|
|
}
|
|
if in.Threshold == 0 || in.Threshold > in.N {
|
|
return nil, remainingGas, ErrBadThreshold
|
|
}
|
|
|
|
// Source the C tx identity for the deterministic id. The atomic capability supplies
|
|
// the network-scoped chain ids + tx id + call index; without it the bridge cannot
|
|
// mint a rail-scoped id and reverts (it will NOT guess a peer). This mirrors dex's
|
|
// AtomicState requirement on its native seam.
|
|
atomicState, ok := accessibleState.(contract.AtomicState)
|
|
if !ok {
|
|
return nil, remainingGas, ErrNoAtomicState
|
|
}
|
|
cChainID := atomicState.CChainID()
|
|
copy(in.CChainID[:], cChainID[:])
|
|
txID := atomicState.TxID()
|
|
// c_tx_hash: prefer the EVM tx hash (StateDB.TxHash()); fall back to the atomic
|
|
// TxID when the StateDB hash is zero (some harnesses only set one). Both are the
|
|
// same logical "this C tx" identity.
|
|
txHash := accessibleState.GetStateDB().TxHash()
|
|
if txHash == (common.Hash{}) {
|
|
copy(in.CTxHash[:], txID[:])
|
|
} else {
|
|
copy(in.CTxHash[:], txHash[:])
|
|
}
|
|
// Fail-secure: c_tx_hash is a load-bearing component of the deterministic id (it
|
|
// makes the id injective per C tx). A zero value means BOTH the EVM tx hash and the
|
|
// atomic TxID were unset — a broken host wiring under which two distinct C txs would
|
|
// alias the same id. Reject rather than mint an unbound id (same principle as the
|
|
// zero model/prompt/AChainID guards). In a correctly-wired C-Chain this never trips
|
|
// (StateDB.TxHash() is always a unique non-zero value).
|
|
if isZero32(in.CTxHash) {
|
|
return nil, remainingGas, ErrZeroTxHash
|
|
}
|
|
in.CallIndex = atomicState.CallIndex()
|
|
|
|
// The installed client (default achainUnavailable until a node wires its A on-ramp).
|
|
client := currentAChainClient()
|
|
// Bind the rail peer from the client (the committed C<->A peer id).
|
|
in.AChainID = client.AChainID()
|
|
|
|
// Construct the C-state store (the ONLY place value-class state is written), stamped
|
|
// at the current block height.
|
|
blockNumber := accessibleState.GetBlockContext().Number().Uint64()
|
|
store := newStateKVStore(accessibleState.GetStateDB(), blockNumber)
|
|
|
|
ctx := accessibleState.GetConsensusContext()
|
|
intentID, serr := client.SubmitInferenceIntent(ctx, store, in)
|
|
if serr != nil {
|
|
// Clean revert (includes ErrAChainUnavailable when no local A on-ramp, and
|
|
// ErrIntentReplay when the id was already recorded).
|
|
return nil, remainingGas, serr
|
|
}
|
|
|
|
out := make([]byte, 32)
|
|
copy(out, intentID[:])
|
|
return out, remainingGas, nil
|
|
}
|
|
|
|
// --- Pattern B: VerifyInferenceReceipt ----------------------------------------
|
|
//
|
|
// calldata (after the 4-byte selector) is a 2-arg dynamic-bytes ABI frame, but we use
|
|
// a TIGHT fixed-header encoding for determinism (no Solidity-ABI offset ambiguity):
|
|
//
|
|
// [0:2] u16be receiptLen
|
|
// [2:4] u16be proofLen
|
|
// [4:4+rl] receipt bytes (canonical AInferenceReceipt encoding)
|
|
// [..] proof bytes (DecodeProof wire encoding)
|
|
//
|
|
// returns 3 ABI-ish words (96 bytes):
|
|
//
|
|
// [0:32] intentID (bytes32)
|
|
// [32:64] canonicalOutputHash (bytes32)
|
|
// [64:96] status (uint8, right-aligned) — always 2 (Completed) on success
|
|
func verifyReceipt(
|
|
accessibleState contract.AccessibleState,
|
|
args []byte,
|
|
suppliedGas uint64,
|
|
readOnly bool,
|
|
) ([]byte, uint64, error) {
|
|
// Decode the framing first (cheap) so verify gas can include the proof depth, but
|
|
// charge a minimum base before any work to bound griefing on a malformed frame.
|
|
const headLen = 4
|
|
if suppliedGas < GasVerifyInferenceReceiptBase {
|
|
return nil, 0, fmt.Errorf("aivmbridge: out of gas: need %d have %d", GasVerifyInferenceReceiptBase, suppliedGas)
|
|
}
|
|
if readOnly {
|
|
// Verify mutates the C outbox (marks consumed); a static call reverts. Charge
|
|
// the base then revert (the base was affordable per the check above).
|
|
return nil, suppliedGas - GasVerifyInferenceReceiptBase, ErrReadOnly
|
|
}
|
|
if len(args) < headLen {
|
|
return nil, suppliedGas - GasVerifyInferenceReceiptBase, ErrInputTooShort
|
|
}
|
|
receiptLen := int(binary.BigEndian.Uint16(args[0:2]))
|
|
proofLen := int(binary.BigEndian.Uint16(args[2:4]))
|
|
want := headLen + receiptLen + proofLen
|
|
if len(args) != want {
|
|
// Reject truncation AND trailing junk — the frame is exact.
|
|
return nil, suppliedGas - GasVerifyInferenceReceiptBase, ErrInputOversized
|
|
}
|
|
receiptBytes := args[headLen : headLen+receiptLen]
|
|
proofBytes := args[headLen+receiptLen:]
|
|
|
|
r, derr := DecodeReceipt(receiptBytes)
|
|
if derr != nil {
|
|
return nil, suppliedGas - GasVerifyInferenceReceiptBase, derr
|
|
}
|
|
p, perr := DecodeProof(proofBytes)
|
|
if perr != nil {
|
|
return nil, suppliedGas - GasVerifyInferenceReceiptBase, perr
|
|
}
|
|
|
|
// Now we know the proof depth — charge the full metered gas (base + per-node).
|
|
gas := verifyGas(len(p.Path))
|
|
if suppliedGas < gas {
|
|
return nil, 0, fmt.Errorf("aivmbridge: out of gas: need %d have %d", gas, suppliedGas)
|
|
}
|
|
remainingGas := suppliedGas - gas
|
|
|
|
blockNumber := accessibleState.GetBlockContext().Number().Uint64()
|
|
vs := newStateKVStore(accessibleState.GetStateDB(), blockNumber)
|
|
|
|
// Pure C-state verification (NO aivm query). On success it marks the intent
|
|
// consumed and returns the verified output.
|
|
verified, verr := verifyInferenceReceipt(vs, r, p)
|
|
if verr != nil {
|
|
return nil, remainingGas, verr
|
|
}
|
|
|
|
out := make([]byte, 96)
|
|
copy(out[0:32], verified.IntentID[:])
|
|
copy(out[32:64], verified.CanonicalOutputHash[:])
|
|
out[95] = byte(verified.Status)
|
|
return out, remainingGas, nil
|
|
}
|
|
|
|
// --- ABI word decoders (fixed-width, right-aligned, dirty-byte rejecting) ------
|
|
|
|
// readUint16Word reads a uint16 from the LOW 2 bytes of a 32-byte ABI word and
|
|
// REJECTS any non-zero byte in the high 30 bytes (a caller must not smuggle bits
|
|
// above the declared width — that is the calldata-hardening discipline).
|
|
func readUint16Word(word []byte) (uint16, error) {
|
|
if len(word) < 32 {
|
|
return 0, ErrInputTooShort
|
|
}
|
|
for i := 0; i < 30; i++ {
|
|
if word[i] != 0 {
|
|
return 0, ErrDirtyWord
|
|
}
|
|
}
|
|
return binary.BigEndian.Uint16(word[30:32]), nil
|
|
}
|