Skip to content
Closed
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
84 changes: 84 additions & 0 deletions examples/file_update_chunked/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"fmt"
"os"
"strings"

hiero "github.com/hiero-ledger/hiero-sdk-go/v2/sdk"
)

func main() {
var client *hiero.Client
var err error

// Retrieving network type from environment variable HEDERA_NETWORK
client, err = hiero.ClientForName(os.Getenv("HEDERA_NETWORK"))
if err != nil {
panic(fmt.Sprintf("%v : error creating client", err))
}

// Retrieving operator ID from environment variable OPERATOR_ID
operatorAccountID, err := hiero.AccountIDFromString(os.Getenv("OPERATOR_ID"))
if err != nil {
panic(fmt.Sprintf("%v : error converting string to AccountID", err))
}

// Retrieving operator key from environment variable OPERATOR_KEY
operatorKey, err := hiero.PrivateKeyFromString(os.Getenv("OPERATOR_KEY"))
if err != nil {
panic(fmt.Sprintf("%v : error converting string to PrivateKey", err))
}

// Setting the client operator ID and key
client.SetOperator(operatorAccountID, operatorKey)

// Create a small file to start with
newFileResponse, err := hiero.NewFileCreateTransaction().
SetKeys(client.GetOperatorPublicKey()).
SetContents([]byte("Hello from hiero.")).
SetMemo("go file update chunked example").
SetMaxTransactionFee(hiero.NewHbar(2)).
Execute(client)
if err != nil {
panic(fmt.Sprintf("%v : error creating file", err))
}

receipt, err := newFileResponse.GetReceipt(client)
if err != nil {
panic(fmt.Sprintf("%v : error retrieving file creation receipt", err))
}
fileID := *receipt.FileID

// Contents larger than the ~6 KiB single-transaction limit. ExecuteAll transparently overwrites
// the file with the first chunk and appends the remainder, so this one call replaces the whole
// file with content that could never fit in a single FileUpdateTransaction.
//
// Note: each chunk is a separate network transaction charged its own fee, and each is signed
// with the operator only. If the file needs additional keys, or you want explicit control of the
// FileID / per-chunk fees / error recovery, use the manual FileUpdate + FileAppend two-step.
bigContents := strings.Repeat("The quick brown fox jumps over the lazy dog. ", 400)

responses, err := hiero.NewFileUpdateTransaction().
SetNodeAccountIDs([]hiero.AccountID{newFileResponse.NodeID}).
SetFileID(fileID).
SetContents([]byte(bigContents)).
SetMaxTransactionFee(hiero.NewHbar(5)).
ExecuteAll(client)
if err != nil {
panic(fmt.Sprintf("%v : error executing chunked file update", err))
}

fmt.Printf("Chunked update ran as %d transactions (1 update + %d appends)\n", len(responses), len(responses)-1)

// Confirm the whole file was replaced
info, err := hiero.NewFileInfoQuery().
SetNodeAccountIDs([]hiero.AccountID{newFileResponse.NodeID}).
SetFileID(fileID).
Execute(client)
if err != nil {
panic(fmt.Sprintf("%v : error executing file info query", err))
}

fmt.Printf("Uploaded %d bytes; file size according to FileInfoQuery: %d\n", len(bigContents), info.Size)
}
4 changes: 4 additions & 0 deletions sdk/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ var errEvmAddressIsNotCorrectSize = errors.New("EVM address is not the correct s
var errInvalidChunkSize = errors.New("chunk size must be greater than 0")
var errPublicKeyHasNotSigned = errors.New("the public key has not signed this transaction")

// FileUpdateTransaction auto-chunking (ExecuteAll) errors
var errFileUpdateChunkingRequiresFileID = errors.New("FileUpdateTransaction requires a FileID to update contents larger than the chunk size")
var errFileUpdateChunkingRequiresUnfrozen = errors.New("FileUpdateTransaction with contents larger than the chunk size must not be frozen before ExecuteAll")

// Endpoint validation errors
var errEndpointMustHaveAddressOrDomainName = errors.New("endpoint must have either address or domain name")
var errEndpointCannotHaveBothAddressAndDomainName = errors.New("endpoint must have either address or domain name, but not both")
Expand Down
130 changes: 129 additions & 1 deletion sdk/file_update_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type FileUpdateTransaction struct {
expirationTime *time.Time
contents []byte
memo string
maxChunks uint64
chunkSize int
}

// NewFileUpdateTransaction creates a FileUpdateTransaction which modifies the metadata and/or contents of a file.
Expand All @@ -33,7 +35,10 @@ type FileUpdateTransaction struct {
// additional KeyList or ThresholdKey then M-of-M secondary KeyList or ThresholdKey signing
// requirements must be meet
func NewFileUpdateTransaction() *FileUpdateTransaction {
tx := &FileUpdateTransaction{}
tx := &FileUpdateTransaction{
maxChunks: 20,
chunkSize: 2048,
}
tx.Transaction = _NewTransaction(tx)

return tx
Expand All @@ -57,6 +62,8 @@ func _FileUpdateTransactionFromProtobuf(tx Transaction[*FileUpdateTransaction],
expirationTime: expiration,
contents: pb.GetFileUpdate().GetContents(),
memo: pb.GetFileUpdate().GetMemo().Value,
maxChunks: 20,
chunkSize: 2048,
}

tx.childTransaction = &fileUpdateTransaction
Expand Down Expand Up @@ -130,6 +137,33 @@ func (tx *FileUpdateTransaction) GetContents() []byte {
return tx.contents
}

// SetMaxChunkSize sets the maximum size of each chunk used by ExecuteAll when the contents are
// larger than a single transaction can hold. Defaults to 2048 bytes, matching FileAppendTransaction.
func (tx *FileUpdateTransaction) SetMaxChunkSize(size int) *FileUpdateTransaction {
tx._RequireNotFrozen()
tx.chunkSize = size
return tx
}

// GetMaxChunkSize returns the maximum size of each chunk used by ExecuteAll.
func (tx *FileUpdateTransaction) GetMaxChunkSize() int {
return tx.chunkSize
}

// SetMaxChunks sets the maximum number of chunks ExecuteAll is allowed to split the contents into.
// Defaults to 20, matching FileAppendTransaction. ExecuteAll returns ErrMaxChunksExceeded if the
// contents require more chunks than this.
func (tx *FileUpdateTransaction) SetMaxChunks(size uint64) *FileUpdateTransaction {
tx._RequireNotFrozen()
tx.maxChunks = size
return tx
}

// GetMaxChunks returns the maximum number of chunks ExecuteAll is allowed to split the contents into.
func (tx *FileUpdateTransaction) GetMaxChunks() uint64 {
return tx.maxChunks
}

// SetFileMemo Sets the new memo to be associated with the file (UTF-8 encoding max 100 bytes)
func (tx *FileUpdateTransaction) SetFileMemo(memo string) *FileUpdateTransaction {
tx._RequireNotFrozen()
Expand All @@ -149,6 +183,100 @@ func (tx *FileUpdateTransaction) GetFileMemo() string {
return tx.memo
}

// ExecuteAll updates the file, transparently splitting contents larger than a single transaction
// can hold. When the contents fit in one chunk it behaves like Execute and returns a single-element
// slice; otherwise the first chunk overwrites the file via the FileUpdate and the remainder is
// appended with a FileAppendTransaction, so a file larger than the ~6 KiB single-transaction limit
// can be updated in one call. The returned slice holds the FileUpdate response followed by one
// response per append chunk.
//
// Each sub-transaction is charged its own fee and is signed with the operator only. If the file
// requires additional keys, or you need explicit control of the FileID, per-transaction fees, error
// recovery between chunks, or scheduling, use the manual FileUpdateTransaction +
// FileAppendTransaction two-step instead. Execute keeps its single-transaction semantics and still
// returns TRANSACTION_OVERSIZE for oversized contents.
func (tx *FileUpdateTransaction) ExecuteAll(client *Client) ([]TransactionResponse, error) {
if client == nil || client.operator == nil {
return nil, errNoClientProvided
}
if tx.freezeError != nil {
return nil, tx.freezeError
}

chunkSize := tx.chunkSize
if chunkSize <= 0 {
chunkSize = 2048
}

chunks := uint64((len(tx.contents) + chunkSize - 1) / chunkSize)
if chunks == 0 {
chunks = 1
}
if chunks > tx.maxChunks {
return nil, ErrMaxChunksExceeded{Chunks: chunks, MaxChunks: tx.maxChunks}
}

// Fits in a single transaction: behave exactly like Execute.
if chunks <= 1 {
resp, err := tx.Execute(client)
return []TransactionResponse{resp}, err
}

// Chunking rebuilds the bodies and appends by FileID, so the transaction must be unfrozen and
// carry a FileID.
if tx.IsFrozen() {
return nil, errFileUpdateChunkingRequiresUnfrozen
}
if tx.fileID == nil {
return nil, errFileUpdateChunkingRequiresFileID
}

fullContents := tx.contents
firstChunkEnd := min(chunkSize, len(fullContents))

// The first chunk overwrites the file's contents via the update itself; wait for its receipt
// before appending the rest to preserve ordering.
tx.SetContents(fullContents[:firstChunkEnd])
updateResponse, err := tx.Execute(client)
if err != nil {
return []TransactionResponse{updateResponse}, err
}
if _, err := updateResponse.SetValidateStatus(true).GetReceipt(client); err != nil {
return []TransactionResponse{updateResponse}, err
}

appendTx := NewFileAppendTransaction().
SetFileID(*tx.fileID).
SetContents(fullContents[firstChunkEnd:]).
SetMaxChunkSize(chunkSize).
SetMaxChunks(tx.maxChunks)
if nodeAccountIDs := tx.GetNodeAccountIDs(); len(nodeAccountIDs) > 0 {
appendTx.SetNodeAccountIDs(nodeAccountIDs)
}

appendResponses, err := appendTx.ExecuteAll(client)
responses := append([]TransactionResponse{updateResponse}, appendResponses...)
return responses, err
}

// Schedule creates a ScheduleCreateTransaction for this FileUpdateTransaction. Chunked contents
// (more than one chunk) cannot be scheduled, mirroring FileAppendTransaction.Schedule.
func (tx *FileUpdateTransaction) Schedule() (*ScheduleCreateTransaction, error) {
chunkSize := tx.chunkSize
if chunkSize <= 0 {
chunkSize = 2048
}
chunks := uint64((len(tx.contents) + chunkSize - 1) / chunkSize)
if chunks > 1 {
return &ScheduleCreateTransaction{}, ErrMaxChunksExceeded{
Chunks: chunks,
MaxChunks: 1,
}
}

return tx.Transaction.Schedule()
}

// ----------- Overridden functions ----------------

func (tx FileUpdateTransaction) getName() string {
Expand Down
93 changes: 93 additions & 0 deletions sdk/file_update_transaction_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package hiero
// SPDX-License-Identifier: Apache-2.0

import (
"bytes"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -100,3 +102,94 @@ func TestIntegrationFileUpdateTransactionNoFileID(t *testing.T) {
_, err = resp.SetValidateStatus(true).GetReceipt(env.Client)
require.NoError(t, err)
}

// FileUpdate content auto-chunking via ExecuteAll

func TestIntegrationFileUpdateTransactionExecuteAllChunked(t *testing.T) {
t.Parallel()
env := NewIntegrationTestEnv(t)
defer CloseIntegrationTestEnv(env, nil)

resp, err := NewFileCreateTransaction().
SetKeys(env.Client.GetOperatorPublicKey()).
SetNodeAccountIDs(env.NodeAccountIDs).
SetContents([]byte("initial")).
Execute(env.Client)
require.NoError(t, err)

receipt, err := resp.SetValidateStatus(true).GetReceipt(env.Client)
require.NoError(t, err)
fileID := *receipt.FileID

var builder bytes.Buffer
for i := 0; builder.Len() < 8192; i++ {
fmt.Fprintf(&builder, "%d ", i)
}
newContents := builder.Bytes()[:8192]

responses, err := NewFileUpdateTransaction().
SetFileID(fileID).
SetNodeAccountIDs([]AccountID{resp.NodeID}).
SetContents(newContents).
ExecuteAll(env.Client)
require.NoError(t, err)
require.Len(t, responses, 4, "8 KiB / 2048 = 4 chunks (1 update + 3 appends)")

contents, err := NewFileContentsQuery().
SetFileID(fileID).
SetNodeAccountIDs([]AccountID{resp.NodeID}).
Execute(env.Client)
require.NoError(t, err)
require.Equal(t, newContents, contents)

// Cross-check the reported size independently of the contents query.
info, err := NewFileInfoQuery().
SetFileID(fileID).
SetNodeAccountIDs([]AccountID{resp.NodeID}).
Execute(env.Client)
require.NoError(t, err)
assert.Equal(t, int64(len(newContents)), info.Size)
}

func TestIntegrationFileUpdateTransactionExecuteAllSingleChunk(t *testing.T) {
t.Parallel()
env := NewIntegrationTestEnv(t)
defer CloseIntegrationTestEnv(env, nil)

resp, err := NewFileCreateTransaction().
SetKeys(env.Client.GetOperatorPublicKey()).
SetNodeAccountIDs(env.NodeAccountIDs).
SetContents([]byte("initial")).
Execute(env.Client)
require.NoError(t, err)

receipt, err := resp.SetValidateStatus(true).GetReceipt(env.Client)
require.NoError(t, err)
fileID := *receipt.FileID

newContents := []byte("small enough for one transaction")

responses, err := NewFileUpdateTransaction().
SetFileID(fileID).
SetNodeAccountIDs([]AccountID{resp.NodeID}).
SetContents(newContents).
ExecuteAll(env.Client)
require.NoError(t, err)
require.Len(t, responses, 1)

_, err = responses[0].SetValidateStatus(true).GetReceipt(env.Client)
require.NoError(t, err)

contents, err := NewFileContentsQuery().
SetFileID(fileID).
SetNodeAccountIDs([]AccountID{resp.NodeID}).
Execute(env.Client)
require.NoError(t, err)
assert.Equal(t, newContents, contents)

_, err = NewFileDeleteTransaction().
SetFileID(fileID).
SetNodeAccountIDs([]AccountID{resp.NodeID}).
Execute(env.Client)
require.NoError(t, err)
}
Loading
Loading