mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
247 lines
6.8 KiB
Go
247 lines
6.8 KiB
Go
package crdt
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Anchorer submits and verifies CRDT state anchors on a Lux chain.
|
|
type Anchorer interface {
|
|
// Submit anchors the given Merkle root at the next logical height.
|
|
Submit(ctx context.Context, root [32]byte) error
|
|
|
|
// Verify checks whether the on-chain anchor at height matches root.
|
|
Verify(ctx context.Context, height uint64, root [32]byte) (bool, error)
|
|
|
|
// LatestHeight returns the highest anchored height for the configured appID.
|
|
LatestHeight(ctx context.Context) (uint64, error)
|
|
}
|
|
|
|
// AnchorConfig configures the background anchoring goroutine.
|
|
type AnchorConfig struct {
|
|
// AppID identifies the application (32-byte hash of app name).
|
|
AppID [32]byte
|
|
|
|
// Interval is the maximum time between anchors. Default: 5 minutes.
|
|
Interval time.Duration
|
|
|
|
// OpThreshold is the number of operations that trigger an anchor.
|
|
// Default: 1000. Set to 0 to disable op-based anchoring.
|
|
OpThreshold int
|
|
|
|
// RPCEndpoint is the JSON-RPC URL of the Lux node.
|
|
RPCEndpoint string
|
|
|
|
// From is the sender address (hex-encoded, with 0x prefix).
|
|
From string
|
|
|
|
// ContractAddress is the anchor precompile address.
|
|
// Default: 0x0700000000000000000000000000000000000010
|
|
ContractAddress string
|
|
}
|
|
|
|
// AppIDFromName computes the 32-byte appID as SHA-256 of the given name.
|
|
func AppIDFromName(name string) [32]byte {
|
|
return sha256.Sum256([]byte(name))
|
|
}
|
|
|
|
// RPCAnchorer implements Anchorer via JSON-RPC calls to a Lux node.
|
|
type RPCAnchorer struct {
|
|
cfg AnchorConfig
|
|
mu sync.Mutex
|
|
height uint64
|
|
rpcCall func(ctx context.Context, method string, params []any) (json.RawMessage, error)
|
|
}
|
|
|
|
// NewRPCAnchorer creates an Anchorer that talks to a Lux node via JSON-RPC.
|
|
// The rpcCall function is injected for testability. Pass nil to use the default
|
|
// HTTP JSON-RPC client targeting cfg.RPCEndpoint.
|
|
func NewRPCAnchorer(cfg AnchorConfig, rpcCall func(ctx context.Context, method string, params []any) (json.RawMessage, error)) *RPCAnchorer {
|
|
if cfg.Interval == 0 {
|
|
cfg.Interval = 5 * time.Minute
|
|
}
|
|
if cfg.OpThreshold == 0 {
|
|
cfg.OpThreshold = 1000
|
|
}
|
|
if cfg.ContractAddress == "" {
|
|
cfg.ContractAddress = "0x0700000000000000000000000000000000000010"
|
|
}
|
|
return &RPCAnchorer{cfg: cfg, rpcCall: rpcCall}
|
|
}
|
|
|
|
// Submit sends an anchor transaction via eth_sendTransaction.
|
|
func (a *RPCAnchorer) Submit(ctx context.Context, root [32]byte) error {
|
|
a.mu.Lock()
|
|
a.height++
|
|
h := a.height
|
|
a.mu.Unlock()
|
|
|
|
calldata := encodeSubmitCalldata(a.cfg.AppID, h, root)
|
|
|
|
params := []any{map[string]string{
|
|
"from": a.cfg.From,
|
|
"to": a.cfg.ContractAddress,
|
|
"data": "0x" + hex.EncodeToString(calldata),
|
|
}}
|
|
|
|
_, err := a.rpcCall(ctx, "eth_sendTransaction", params)
|
|
if err != nil {
|
|
return fmt.Errorf("anchor submit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verify queries the anchor precompile via eth_call and compares roots.
|
|
func (a *RPCAnchorer) Verify(ctx context.Context, height uint64, root [32]byte) (bool, error) {
|
|
calldata := encodeGetCalldata(a.cfg.AppID, height)
|
|
|
|
params := []any{map[string]string{
|
|
"to": a.cfg.ContractAddress,
|
|
"data": "0x" + hex.EncodeToString(calldata),
|
|
}, "latest"}
|
|
|
|
result, err := a.rpcCall(ctx, "eth_call", params)
|
|
if err != nil {
|
|
return false, fmt.Errorf("anchor verify: %w", err)
|
|
}
|
|
|
|
var hexResult string
|
|
if err := json.Unmarshal(result, &hexResult); err != nil {
|
|
return false, fmt.Errorf("anchor verify: unmarshal result: %w", err)
|
|
}
|
|
|
|
// Strip 0x prefix
|
|
if len(hexResult) >= 2 && hexResult[:2] == "0x" {
|
|
hexResult = hexResult[2:]
|
|
}
|
|
|
|
chainRoot, err := hex.DecodeString(hexResult)
|
|
if err != nil {
|
|
return false, fmt.Errorf("anchor verify: decode hex: %w", err)
|
|
}
|
|
|
|
if len(chainRoot) < 32 {
|
|
return false, errors.New("anchor verify: chain returned < 32 bytes")
|
|
}
|
|
|
|
var onChain [32]byte
|
|
copy(onChain[:], chainRoot[:32])
|
|
return onChain == root, nil
|
|
}
|
|
|
|
// LatestHeight queries the latest anchored height via eth_call.
|
|
func (a *RPCAnchorer) LatestHeight(ctx context.Context) (uint64, error) {
|
|
calldata := encodeGetLatestCalldata(a.cfg.AppID)
|
|
|
|
params := []any{map[string]string{
|
|
"to": a.cfg.ContractAddress,
|
|
"data": "0x" + hex.EncodeToString(calldata),
|
|
}, "latest"}
|
|
|
|
result, err := a.rpcCall(ctx, "eth_call", params)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("anchor latestHeight: %w", err)
|
|
}
|
|
|
|
var hexResult string
|
|
if err := json.Unmarshal(result, &hexResult); err != nil {
|
|
return 0, fmt.Errorf("anchor latestHeight: unmarshal: %w", err)
|
|
}
|
|
|
|
if len(hexResult) >= 2 && hexResult[:2] == "0x" {
|
|
hexResult = hexResult[2:]
|
|
}
|
|
|
|
b, err := hex.DecodeString(hexResult)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("anchor latestHeight: decode: %w", err)
|
|
}
|
|
|
|
if len(b) < 32 {
|
|
return 0, nil
|
|
}
|
|
return binary.BigEndian.Uint64(b[24:32]), nil
|
|
}
|
|
|
|
// AnchorBackground runs a background goroutine that periodically anchors
|
|
// the document's state. Cancel the context to stop it.
|
|
func AnchorBackground(ctx context.Context, doc *Document, anchorer Anchorer, cfg AnchorConfig) {
|
|
if cfg.Interval == 0 {
|
|
cfg.Interval = 5 * time.Minute
|
|
}
|
|
|
|
ticker := time.NewTicker(cfg.Interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
root, err := DocumentMerkleRoot(doc)
|
|
if err != nil {
|
|
log.Printf("anchor: compute root: %v", err)
|
|
continue
|
|
}
|
|
if err := anchorer.Submit(ctx, root); err != nil {
|
|
log.Printf("anchor: submit: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DocumentMerkleRoot computes SHA-256 of the gob-encoded document snapshot.
|
|
func DocumentMerkleRoot(doc *Document) ([32]byte, error) {
|
|
data, err := doc.Encode()
|
|
if err != nil {
|
|
return [32]byte{}, fmt.Errorf("merkle root: encode: %w", err)
|
|
}
|
|
return sha256.Sum256(data), nil
|
|
}
|
|
|
|
// --- ABI encoding helpers ---
|
|
// These produce minimal ABI-compatible calldata without importing the full ABI library.
|
|
|
|
// Function selector: keccak256("submit(bytes32,uint64,bytes32)")[:4]
|
|
// Precomputed to avoid runtime dependency on crypto/keccak.
|
|
var (
|
|
// submit(bytes32,uint64,bytes32) selector
|
|
selectorSubmit = [4]byte{0x3c, 0x21, 0xe8, 0xa1}
|
|
// get(bytes32,uint64) selector
|
|
selectorGet = [4]byte{0x8e, 0xaa, 0x6a, 0xc0}
|
|
// getLatest(bytes32) selector
|
|
selectorGetLatest = [4]byte{0xba, 0x41, 0xd8, 0x47}
|
|
)
|
|
|
|
func encodeSubmitCalldata(appID [32]byte, height uint64, root [32]byte) []byte {
|
|
buf := make([]byte, 4+96)
|
|
copy(buf[:4], selectorSubmit[:])
|
|
copy(buf[4:36], appID[:])
|
|
binary.BigEndian.PutUint64(buf[60:68], height)
|
|
copy(buf[68:100], root[:])
|
|
return buf
|
|
}
|
|
|
|
func encodeGetCalldata(appID [32]byte, height uint64) []byte {
|
|
buf := make([]byte, 4+64)
|
|
copy(buf[:4], selectorGet[:])
|
|
copy(buf[4:36], appID[:])
|
|
binary.BigEndian.PutUint64(buf[60:68], height)
|
|
return buf
|
|
}
|
|
|
|
func encodeGetLatestCalldata(appID [32]byte) []byte {
|
|
buf := make([]byte, 4+32)
|
|
copy(buf[:4], selectorGetLatest[:])
|
|
copy(buf[4:36], appID[:])
|
|
return buf
|
|
}
|