Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
cosmossdk.io/math v1.5.3
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/DataDog/zstd v1.5.7
github.com/LumeraProtocol/lumera v1.11.0-rc
github.com/LumeraProtocol/lumera v1.11.1-0.20260308102614-4d4f1ce3f65e
github.com/LumeraProtocol/rq-go v0.2.1
github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce
github.com/cenkalti/backoff/v4 v4.3.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/LumeraProtocol/lumera v1.11.0-rc h1:ISJLUhjihuOterLMHpgGWpMZmybR1vmQLNgmSHkc1WA=
github.com/LumeraProtocol/lumera v1.11.0-rc/go.mod h1:p2sZZG3bLzSBdaW883qjuU3DXXY4NJzTTwLywr8uI0w=
github.com/LumeraProtocol/lumera v1.11.1-0.20260308102614-4d4f1ce3f65e h1:acxjs0ki/uNv9+b/x5dcUzGoi+lea4E8QMdJx805svU=
github.com/LumeraProtocol/lumera v1.11.1-0.20260308102614-4d4f1ce3f65e/go.mod h1:p2sZZG3bLzSBdaW883qjuU3DXXY4NJzTTwLywr8uI0w=
github.com/LumeraProtocol/rq-go v0.2.1 h1:8B3UzRChLsGMmvZ+UVbJsJj6JZzL9P9iYxbdUwGsQI4=
github.com/LumeraProtocol/rq-go v0.2.1/go.mod h1:APnKCZRh1Es2Vtrd2w4kCLgAyaL5Bqrkz/BURoRJ+O8=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
Expand Down
212 changes: 212 additions & 0 deletions pkg/cascadekit/commitment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package cascadekit

import (
"fmt"
"io"
"os"

actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types"
"github.com/LumeraProtocol/lumera/x/action/v1/merkle"
"lukechampine.com/blake3"
)

const (
// DefaultChunkSize is the default chunk size for LEP-5 commitment (256 KiB).
DefaultChunkSize = 262144
// MinChunkSize is the minimum allowed chunk size.
MinChunkSize = 1
// MaxChunkSize is the maximum allowed chunk size.
MaxChunkSize = 262144
// MinTotalSize is the minimum file size for LEP-5 commitment.
MinTotalSize = 4
// CommitmentType is the commitment type constant for LEP-5.
CommitmentType = "lep5/chunk-merkle/v1"
)

// SelectChunkSize returns the optimal chunk size for a given file size and
// minimum chunk count. It starts at DefaultChunkSize and halves until the
// file produces at least minChunks chunks.
func SelectChunkSize(fileSize int64, minChunks uint32) uint32 {
s := uint32(DefaultChunkSize)
for numChunks(fileSize, s) < minChunks && s > MinChunkSize {
s /= 2
}
return s
}

func numChunks(fileSize int64, chunkSize uint32) uint32 {
n := uint32(fileSize / int64(chunkSize))
if fileSize%int64(chunkSize) != 0 {
n++
}
return n
}

// ChunkFile reads a file and returns its chunks using the given chunk size.
func ChunkFile(path string, chunkSize uint32) ([][]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer f.Close()

fi, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}

totalSize := fi.Size()
n := numChunks(totalSize, chunkSize)
chunks := make([][]byte, 0, n)

buf := make([]byte, chunkSize)
for {
nr, err := io.ReadFull(f, buf)
if nr > 0 {
chunk := make([]byte, nr)
copy(chunk, buf[:nr])
chunks = append(chunks, chunk)
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return nil, fmt.Errorf("read chunk: %w", err)
}
}
return chunks, nil
}

// BuildCommitmentFromFile constructs an AvailabilityCommitment for a file.
// It chunks the file, builds a Merkle tree, and generates challenge indices.
// challengeCount and minChunks are the SVC parameters from the chain.
func BuildCommitmentFromFile(filePath string, challengeCount, minChunks uint32) (*actiontypes.AvailabilityCommitment, *merkle.Tree, error) {
fi, err := os.Stat(filePath)
if err != nil {
return nil, nil, fmt.Errorf("stat file: %w", err)
}
totalSize := fi.Size()
if totalSize < MinTotalSize {
return nil, nil, fmt.Errorf("file too small: %d bytes (minimum %d)", totalSize, MinTotalSize)
}

chunkSize := SelectChunkSize(totalSize, minChunks)
nc := numChunks(totalSize, chunkSize)
if nc < minChunks {
return nil, nil, fmt.Errorf("file produces %d chunks, need at least %d", nc, minChunks)
}

chunks, err := ChunkFile(filePath, chunkSize)
if err != nil {
return nil, nil, err
}

tree, err := merkle.BuildTree(chunks)
if err != nil {
return nil, nil, fmt.Errorf("build merkle tree: %w", err)
}

// Generate challenge indices — simple deterministic selection using tree root as entropy.
m := challengeCount
if m > nc {
m = nc
}
indices := deriveSimpleIndices(tree.Root[:], nc, m)

commitment := &actiontypes.AvailabilityCommitment{
CommitmentType: CommitmentType,
HashAlgo: actiontypes.HashAlgo_HASH_ALGO_BLAKE3,
ChunkSize: chunkSize,
TotalSize: uint64(totalSize),
NumChunks: nc,
Root: tree.Root[:],
ChallengeIndices: indices,
}

return commitment, tree, nil
}

// GenerateChunkProofs produces Merkle proofs for the challenge indices in the commitment.
func GenerateChunkProofs(tree *merkle.Tree, indices []uint32) ([]*actiontypes.ChunkProof, error) {
proofs := make([]*actiontypes.ChunkProof, len(indices))
for i, idx := range indices {
p, err := tree.GenerateProof(int(idx))
if err != nil {
return nil, fmt.Errorf("generate proof for chunk %d: %w", idx, err)
}

pathHashes := make([][]byte, len(p.PathHashes))
for j, h := range p.PathHashes {
pathHashes[j] = h[:]
}

proofs[i] = &actiontypes.ChunkProof{
ChunkIndex: p.ChunkIndex,
LeafHash: p.LeafHash[:],
PathHashes: pathHashes,
PathDirections: p.PathDirections,
}
}
return proofs, nil
}

// VerifyCommitmentRoot rebuilds the Merkle tree from a file and checks it matches the on-chain root.
func VerifyCommitmentRoot(filePath string, commitment *actiontypes.AvailabilityCommitment) (*merkle.Tree, error) {
if commitment == nil {
return nil, nil // pre-LEP-5 action, nothing to verify
}

chunks, err := ChunkFile(filePath, commitment.ChunkSize)
if err != nil {
return nil, fmt.Errorf("chunk file for verification: %w", err)
}

if uint32(len(chunks)) != commitment.NumChunks {
return nil, fmt.Errorf("chunk count mismatch: got %d, expected %d", len(chunks), commitment.NumChunks)
}

tree, err := merkle.BuildTree(chunks)
if err != nil {
return nil, fmt.Errorf("build merkle tree for verification: %w", err)
}

if tree.Root != [merkle.HashSize]byte(commitment.Root) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slice-to-array conversion [merkle.HashSize]byte(commitment.Root) panics if commitment.Root is not exactly 32 bytes. While the chain validates root length at registration, the supernode deserializes this from a protobuf message received over the network -- a corrupted or truncated payload could produce a root of unexpected length. Adding a length guard before the conversion would prevent a runtime panic.

Suggested change
if tree.Root != [merkle.HashSize]byte(commitment.Root) {
if len(commitment.Root) != merkle.HashSize {
return nil, fmt.Errorf("invalid root length: got %d, expected %d", len(commitment.Root), merkle.HashSize)
}
if tree.Root != [merkle.HashSize]byte(commitment.Root) {

Fix it with Roo Code or mention @roomote and request a fix.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[merkle.HashSize]byte(commitment.Root) will panic at runtime if commitment.Root is not exactly 32 bytes. Since commitment.Root comes from on-chain protobuf deserialization, a malformed or truncated value would crash the supernode. The chain-side equivalent (bytesToMerkleHash in svc.go) validates the length before converting. Consider adding a length check here, or extracting a helper similar to the chain's approach.

Suggested change
if tree.Root != [merkle.HashSize]byte(commitment.Root) {
var expectedRoot [merkle.HashSize]byte
if len(commitment.Root) != merkle.HashSize {
return nil, fmt.Errorf("invalid commitment root length: got %d, expected %d", len(commitment.Root), merkle.HashSize)
}
copy(expectedRoot[:], commitment.Root)
if tree.Root != expectedRoot {

Fix it with Roo Code or mention @roomote and request a fix.

return nil, fmt.Errorf("merkle root mismatch: computed %x, expected %x", tree.Root[:], commitment.Root)
}

return tree, nil
}

// deriveSimpleIndices generates m unique indices in [0, numChunks) using BLAKE3(root || counter).
func deriveSimpleIndices(root []byte, numChunks, m uint32) []uint32 {
if numChunks == 0 || m == 0 {
return nil
}

indices := make([]uint32, 0, m)
used := make(map[uint32]struct{}, m)
counter := uint32(0)

for uint32(len(indices)) < m {
// BLAKE3(root || uint32be(counter))
buf := make([]byte, len(root)+4)
copy(buf, root)
buf[len(root)] = byte(counter >> 24)
buf[len(root)+1] = byte(counter >> 16)
buf[len(root)+2] = byte(counter >> 8)
buf[len(root)+3] = byte(counter)
Comment on lines +190 to +197
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: buf is re-allocated every iteration but its length (len(root)+4) is constant. Moving the allocation before the loop and just overwriting the counter bytes each iteration avoids unnecessary GC pressure.

Suggested change
for uint32(len(indices)) < m {
// BLAKE3(root || uint32be(counter))
buf := make([]byte, len(root)+4)
copy(buf, root)
buf[len(root)] = byte(counter >> 24)
buf[len(root)+1] = byte(counter >> 16)
buf[len(root)+2] = byte(counter >> 8)
buf[len(root)+3] = byte(counter)
buf := make([]byte, len(root)+4)
copy(buf, root)
for uint32(len(indices)) < m {
// BLAKE3(root || uint32be(counter))
buf[len(root)] = byte(counter >> 24)
buf[len(root)+1] = byte(counter >> 16)
buf[len(root)+2] = byte(counter >> 8)
buf[len(root)+3] = byte(counter)

Fix it with Roo Code or mention @roomote and request a fix.


h := blake3.Sum256(buf)
// Use first 8 bytes as uint64 mod numChunks
val := uint64(h[0])<<56 | uint64(h[1])<<48 | uint64(h[2])<<40 | uint64(h[3])<<32 |
uint64(h[4])<<24 | uint64(h[5])<<16 | uint64(h[6])<<8 | uint64(h[7])
idx := uint32(val % uint64(numChunks))

if _, exists := used[idx]; !exists {
used[idx] = struct{}{}
indices = append(indices, idx)
}
counter++
}
return indices
}
9 changes: 7 additions & 2 deletions pkg/cascadekit/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import (

// NewCascadeMetadata creates a types.CascadeMetadata for RequestAction.
// The keeper will populate rq_ids_max; rq_ids_ids is for FinalizeAction only.
func NewCascadeMetadata(dataHashB64, fileName string, rqIdsIc uint64, indexSignatureFormat string, public bool) actiontypes.CascadeMetadata {
return actiontypes.CascadeMetadata{
// commitment may be nil for pre-LEP-5 actions.
func NewCascadeMetadata(dataHashB64, fileName string, rqIdsIc uint64, indexSignatureFormat string, public bool, commitment *actiontypes.AvailabilityCommitment) actiontypes.CascadeMetadata {
meta := actiontypes.CascadeMetadata{
DataHash: dataHashB64,
FileName: fileName,
RqIdsIc: rqIdsIc,
Signatures: indexSignatureFormat,
Public: public,
}
if commitment != nil {
meta.AvailabilityCommitment = commitment
}
return meta
}
2 changes: 1 addition & 1 deletion pkg/cascadekit/request_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ func BuildCascadeRequest(layout codec.Layout, fileBytes []byte, fileName string,
if err != nil {
return actiontypes.CascadeMetadata{}, nil, err
}
meta := NewCascadeMetadata(dataHashB64, fileName, uint64(ic), indexSignatureFormat, public)
meta := NewCascadeMetadata(dataHashB64, fileName, uint64(ic), indexSignatureFormat, public, nil)
return meta, indexIDs, nil
}
17 changes: 9 additions & 8 deletions pkg/lumera/modules/action_msg/action_msg_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions pkg/lumera/modules/action_msg/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ func createRequestActionMessage(creator, actionType, metadata, price, expiration
}
}

func createFinalizeActionMessage(creator, actionId string, rqIdsIds []string) (*actiontypes.MsgFinalizeAction, error) {
func createFinalizeActionMessage(creator, actionId string, rqIdsIds []string, chunkProofs []*actiontypes.ChunkProof) (*actiontypes.MsgFinalizeAction, error) {
cascadeMeta := actiontypes.CascadeMetadata{
RqIdsIds: rqIdsIds,
RqIdsIds: rqIdsIds,
ChunkProofs: chunkProofs,
}

metadataBytes, err := json.Marshal(&cascadeMeta)
Expand Down
8 changes: 4 additions & 4 deletions pkg/lumera/modules/action_msg/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (m *module) RequestAction(ctx context.Context, actionType, metadata, price,
})
}

func (m *module) FinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string) (*sdktx.BroadcastTxResponse, error) {
func (m *module) FinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string, chunkProofs []*actiontypes.ChunkProof) (*sdktx.BroadcastTxResponse, error) {
if err := validateFinalizeActionParams(actionId, rqIdsIds); err != nil {
return nil, err
}
Expand All @@ -68,7 +68,7 @@ func (m *module) FinalizeCascadeAction(ctx context.Context, actionId string, rqI
defer m.mu.Unlock()

return m.txHelper.ExecuteTransaction(ctx, func(creator string) (types.Msg, error) {
return createFinalizeActionMessage(creator, actionId, rqIdsIds)
return createFinalizeActionMessage(creator, actionId, rqIdsIds, chunkProofs)
})
}

Expand All @@ -86,7 +86,7 @@ func (m *module) SetTxHelperConfig(config *txmod.TxHelperConfig) {
// SimulateFinalizeCascadeAction builds the finalize message and performs a simulation
// without broadcasting the transaction. This is useful to ensure the transaction
// would pass ante/ValidateBasic before doing irreversible work.
func (m *module) SimulateFinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string) (*sdktx.SimulateResponse, error) {
func (m *module) SimulateFinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string, chunkProofs []*actiontypes.ChunkProof) (*sdktx.SimulateResponse, error) {
if err := validateFinalizeActionParams(actionId, rqIdsIds); err != nil {
return nil, err
}
Expand All @@ -105,7 +105,7 @@ func (m *module) SimulateFinalizeCascadeAction(ctx context.Context, actionId str
}

// Build the finalize message
msg, err := createFinalizeActionMessage(creator, actionId, rqIdsIds)
msg, err := createFinalizeActionMessage(creator, actionId, rqIdsIds, chunkProofs)
if err != nil {
return nil, err
}
Expand Down
8 changes: 5 additions & 3 deletions pkg/lumera/modules/action_msg/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package action_msg
import (
"context"

actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types"
"github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/auth"
"github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/tx"
"github.com/cosmos/cosmos-sdk/crypto/keyring"
Expand All @@ -12,11 +13,12 @@ import (
)

type Module interface {
// FinalizeCascadeAction finalizes a CASCADE action with the given parameters
// RequestAction submits a new action request
RequestAction(ctx context.Context, actionType, metadata, price, expirationTime, fileSizeKbs string) (*sdktx.BroadcastTxResponse, error)
FinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string) (*sdktx.BroadcastTxResponse, error)
// FinalizeCascadeAction finalizes a CASCADE action with rqIDs and optional LEP-5 chunk proofs
FinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string, chunkProofs []*actiontypes.ChunkProof) (*sdktx.BroadcastTxResponse, error)
// SimulateFinalizeCascadeAction simulates the finalize action (no broadcast)
SimulateFinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string) (*sdktx.SimulateResponse, error)
SimulateFinalizeCascadeAction(ctx context.Context, actionId string, rqIdsIds []string, chunkProofs []*actiontypes.ChunkProof) (*sdktx.SimulateResponse, error)
}

func NewModule(conn *grpc.ClientConn, authmod auth.Module, txmodule tx.Module, kr keyring.Keyring, keyName string, chainID string) (Module, error) {
Expand Down
Loading
Loading