Files
zeekay 929bf86938 schain: M1 — manifest state root (multi-validator safety) + S3 object split
PART A — manifest STATE ROOT (the >1-validator prerequisite M0 omitted):
- state.Root() hashes the committed manifest keyspace deterministically via the
  zapdb prefix iterator NewIteratorWithStartAndPrefix(nil, prefixManifest),
  folding each entry length-prefixed in lexicographic key order. Mirrors
  dexvm/state/state.go:395 StateHash, narrowed to the one keyspace.
- VM.computeStateRoot folds blockHash+height with state.Root() (dexvm/vm.go:1260);
  ProcessBlock stamps BlockResult.StateRoot (dexvm/vm.go:584).
- The root travels in the block HEADER (new 32B field after parentID; block id
  commits to it). Block.Verify recomputes it and REJECTS a mismatch
  (errStateRootMismatch) + Aborts staged writes — the safety gate dexvm computed
  but never compared.
- Tests: determinism across write order, change-sensitivity (object set / etag /
  size / fileIds), Verify-rejects-tampered-root.

PART B — S3 OBJECT path, on-chain-metadata / off-chain-blob split, end to end
(schain/object/):
- Store.PutObject streams the blob to a Volume (OFF chain) -> fid, then commits
  only PutManifest{bucket,object,[fid],size,etag} to the VM through a real block
  (ON chain). GetObject reads the manifest back and reconstructs from the volume.
- object.Volume is the seam; MemVolume satisfies it for M1 with a fid shape
  mirroring hanzo/s3 needle.FileId and etag = base64(md5(blob)) (= ContentMd5).
- M2 PLUG POINT (object/volume.go): swap MemVolume for a github.com/hanzoai/s3
  s3/operation.SubmitFiles adapter; nothing in the VM or object.Store changes.
- Test proves blob bytes appear in NO accepted block, the block stays
  manifest-small, the manifest IS on chain, and GET byte-reconstructs.

luxfi packages only; zapdb-native; no protobuf in state; SDKROOT needed on macOS.
2026-06-25 13:28:14 -07:00

112 lines
3.9 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package object
import (
"context"
"crypto/md5"
"encoding/base64"
"fmt"
)
// Manifest is the on-chain metadata for one object: the file ids of its blobs
// (off chain), the total size, and the content etag. It mirrors the schain
// state.Manifest / txs.PutManifestTx fields — the consensus-sized record that is
// the ONLY thing the object path commits to a block.
type Manifest struct {
FileIDs []string
Size int64
ETag string
}
// Chain is the S-Chain VM surface the object path drives. It is the consensus
// seam: PutManifest submits the metadata tx and SEALS it through a block (the
// caller-supplied sealer plays the consensus engine's build->verify->accept), and
// GetManifest reads committed manifest state back. *schain.ChainVM satisfies this
// via a thin adapter (see the chains/schain object_roundtrip_test.go harness),
// keeping object/ free of an import cycle on package schain.
type Chain interface {
// PutManifest submits a PutManifest{bucket,object,fileIds,size,etag} tx and
// drives it to Accept, returning only after the manifest is committed.
PutManifest(ctx context.Context, bucket, object string, m Manifest) error
// GetManifest returns the committed manifest for (bucket, object).
GetManifest(bucket, object string) (Manifest, bool, error)
}
// Store is the S3 object path: it splits each object into an OFF-CHAIN blob (to
// the Volume) and an ON-CHAIN manifest (to the Chain). It owns neither — it is
// the thin orchestrator that proves the split.
type Store struct {
vol Volume
chain Chain
}
// New builds an object Store over a volume (off-chain blobs) and a chain
// (on-chain manifests).
func New(vol Volume, chain Chain) *Store {
return &Store{vol: vol, chain: chain}
}
// PutObject is the S3 PUT path:
//
// 1. stream the blob to the volume (OFF chain) -> fid; the blob NEVER enters a
// block.
// 2. submit PutManifest{bucket, object, [fid], size, etag} to the chain (ON
// chain) and drive it to Accept.
//
// It returns the committed manifest. ETag is the S3-canonical base64(md5(blob)),
// matching the form hanzo/s3's volume upload returns (ContentMd5), so the on-chain
// etag is identical whether the blob went through the stub or the real volume.
func (s *Store) PutObject(ctx context.Context, bucket, object string, blob []byte) (Manifest, error) {
if len(blob) == 0 {
return Manifest{}, ErrEmptyBlob
}
// (1) OFF-CHAIN: blob -> volume needle -> fid.
fid, err := s.vol.Write(blob)
if err != nil {
return Manifest{}, fmt.Errorf("put object: volume write: %w", err)
}
// (2) ON-CHAIN: the small manifest only.
sum := md5.Sum(blob)
m := Manifest{
FileIDs: []string{fid},
Size: int64(len(blob)),
ETag: base64.StdEncoding.EncodeToString(sum[:]),
}
if err := s.chain.PutManifest(ctx, bucket, object, m); err != nil {
return Manifest{}, fmt.Errorf("put object: commit manifest: %w", err)
}
return m, nil
}
// GetObject is the S3 GET path:
//
// 1. read the manifest from the chain (ON chain) — the fids + size + etag.
// 2. stream each fid's blob from the volume (OFF chain) and concatenate them
// back into the original object bytes.
//
// found is false when no manifest exists for (bucket, object).
func (s *Store) GetObject(ctx context.Context, bucket, object string) (blob []byte, m Manifest, found bool, err error) {
m, found, err = s.chain.GetManifest(bucket, object)
if err != nil {
return nil, Manifest{}, false, fmt.Errorf("get object: read manifest: %w", err)
}
if !found {
return nil, Manifest{}, false, nil
}
// Reassemble the blob from the off-chain needles the manifest names.
out := make([]byte, 0, m.Size)
for _, fid := range m.FileIDs {
part, rerr := s.vol.Read(fid)
if rerr != nil {
return nil, m, true, fmt.Errorf("get object: volume read %s: %w", fid, rerr)
}
out = append(out, part...)
}
return out, m, true, nil
}