From 58fb7610c46dd71bbe5dbd59a6614597399125fa Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Fri, 10 Oct 2025 10:54:39 -0300 Subject: [PATCH 01/15] refactor: update attestation schema and actions for clarity - Simplified the comment in the attestation schema migration by removing the composite primary key reference for the attestations table. - Removed the detailed comment block in the attestation actions migration, streamlining the file for better readability. These changes enhance the clarity and maintainability of the migration files. --- internal/migrations/023-attestation-schema.sql | 2 +- internal/migrations/024-attestation-actions.sql | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/migrations/023-attestation-schema.sql b/internal/migrations/023-attestation-schema.sql index d6dfc6d03..08a135843 100644 --- a/internal/migrations/023-attestation-schema.sql +++ b/internal/migrations/023-attestation-schema.sql @@ -2,7 +2,7 @@ * ATTESTATION SCHEMA MIGRATION * * Creates the essential tables needed for the attestation system: - * - attestations: Stores attestation requests and signatures with composite PK (requester, attestation_hash) + * - attestations: Stores attestation requests and signatures * - attestation_actions: Allowlist of actions permitted for attestation with normalized IDs */ diff --git a/internal/migrations/024-attestation-actions.sql b/internal/migrations/024-attestation-actions.sql index 497ac77a1..cfb31ade4 100644 --- a/internal/migrations/024-attestation-actions.sql +++ b/internal/migrations/024-attestation-actions.sql @@ -1,14 +1,3 @@ -/* - * ATTESTATION ACTIONS MIGRATION - * - * Current scope: - * - request_attestation: User requests signed attestation of query results - * - * Placeholders: - * - sign_attestation – TODO - * - get_signed_attestation / list_attestations – TODO - */ - -- ============================================================================= -- CORE ATTESTATION ACTIONS -- ============================================================================= From 42e9c391da0aec938e8e1b53345061258989c9dc Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Fri, 10 Oct 2025 17:33:54 -0300 Subject: [PATCH 02/15] feat: implement tn_attestation extension for signing and processing attestations - Introduced the tn_attestation extension, which includes functionality for signing attestation payloads using a validator's key, processing attestation hashes, and managing the attestation queue. - Added methods for preparing signing work, submitting signatures, and handling attestation lifecycle events. - Implemented comprehensive tests to ensure the reliability of the signing workflow and attestation processing. - Enhanced the integration with the existing system by ensuring proper logging and error handling throughout the attestation process. These changes improve the overall functionality and maintainability of the attestation system, enabling efficient signing and processing of attestations in the Kwil framework. --- extensions/tn_attestation/broadcast.go | 122 +++++ extensions/tn_attestation/canonical.go | 114 +++++ extensions/tn_attestation/canonical_test.go | 90 ++++ extensions/tn_attestation/extension.go | 267 +++++++++++ .../harness_integration_test.go | 443 ++++++++++++++++++ extensions/tn_attestation/integration_test.go | 363 ++++++++++++++ extensions/tn_attestation/precompile.go | 2 +- extensions/tn_attestation/processor.go | 193 ++++++++ extensions/tn_attestation/processor_test.go | 369 +++++++++++++++ extensions/tn_attestation/signer.go | 40 +- extensions/tn_attestation/signer_test.go | 62 ++- extensions/tn_attestation/tn_attestation.go | 77 ++- .../tn_attestation/tn_attestation_test.go | 128 +++++ extensions/tn_attestation/worker.go | 147 ++++++ .../migrations/024-attestation-actions.sql | 72 ++- .../attestation/attestation_request_test.go | 22 +- 16 files changed, 2435 insertions(+), 76 deletions(-) create mode 100644 extensions/tn_attestation/broadcast.go create mode 100644 extensions/tn_attestation/canonical.go create mode 100644 extensions/tn_attestation/canonical_test.go create mode 100644 extensions/tn_attestation/extension.go create mode 100644 extensions/tn_attestation/harness_integration_test.go create mode 100644 extensions/tn_attestation/integration_test.go create mode 100644 extensions/tn_attestation/processor.go create mode 100644 extensions/tn_attestation/processor_test.go create mode 100644 extensions/tn_attestation/worker.go diff --git a/extensions/tn_attestation/broadcast.go b/extensions/tn_attestation/broadcast.go new file mode 100644 index 000000000..58bc56ae1 --- /dev/null +++ b/extensions/tn_attestation/broadcast.go @@ -0,0 +1,122 @@ +package tn_attestation + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + "time" + + "github.com/trufnetwork/kwil-db/common" + rpcclient "github.com/trufnetwork/kwil-db/core/rpc/client" + userjsonrpc "github.com/trufnetwork/kwil-db/core/rpc/client/user/jsonrpc" + "github.com/trufnetwork/kwil-db/core/types" +) + +// txBroadcasterFunc adapts a plain function into a TxBroadcaster. +type txBroadcasterFunc func(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) + +func (f txBroadcasterFunc) BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) { + return f(ctx, tx, sync) +} + +func (e *signerExtension) ensureBroadcaster(service *common.Service) { + if service == nil || service.LocalConfig == nil { + return + } + if e.Broadcaster() != nil { + return + } + + endpoint := "" + if cfg, ok := service.LocalConfig.Extensions[ExtensionName]; ok { + if v := strings.TrimSpace(cfg["rpc_url"]); v != "" { + endpoint = v + } + } + + if endpoint == "" && service.LocalConfig.RPC.ListenAddress != "" { + endpoint = service.LocalConfig.RPC.ListenAddress + } + if endpoint == "" { + e.Logger().Warn("tn_attestation: cannot build broadcaster (no rpc endpoint configured)") + return + } + + u, err := normalizeListenAddressForClient(endpoint) + if err != nil { + e.Logger().Warn("tn_attestation: invalid rpc endpoint", "endpoint", endpoint, "error", err) + return + } + + e.setBroadcaster(makeBroadcasterFromURL(u)) +} + +func normalizeListenAddressForClient(listen string) (*url.URL, error) { + if listen == "" { + return nil, fmt.Errorf("empty listen address") + } + endpoint := listen + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + endpoint = "http://" + endpoint + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + host, port, err := net.SplitHostPort(u.Host) + if err == nil { + clean := strings.Trim(host, "[]") + if clean == "" { + u.Host = net.JoinHostPort("127.0.0.1", port) + } else if ip := net.ParseIP(clean); ip != nil && ip.IsUnspecified() { + u.Host = net.JoinHostPort("127.0.0.1", port) + } + } + return u, nil +} + +func makeBroadcasterFromURL(u *url.URL) TxBroadcaster { + client := userjsonrpc.NewClient(u) + return txBroadcasterFunc(func(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) { + mode := rpcclient.BroadcastWaitAccept + if sync == uint8(rpcclient.BroadcastWaitCommit) || sync == 1 { + mode = rpcclient.BroadcastWaitCommit + } + hash, err := client.Broadcast(ctx, tx, mode) + if err != nil { + return types.Hash{}, nil, err + } + + var resp *types.TxQueryResponse + var queryErr error + if mode == rpcclient.BroadcastWaitAccept { + for tries := 0; tries < 10; tries++ { + resp, queryErr = client.TxQuery(ctx, hash) + if queryErr == nil && resp != nil && resp.Result != nil { + break + } + select { + case <-ctx.Done(): + return types.Hash{}, nil, ctx.Err() + case <-time.After(200 * time.Millisecond): + } + } + if queryErr != nil { + return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) + } + } else { + resp, queryErr = client.TxQuery(ctx, hash) + if queryErr != nil { + return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) + } + } + + if resp == nil || resp.Result == nil { + return types.Hash{}, nil, fmt.Errorf("transaction result missing") + } + + return hash, resp.Result, nil + }) +} diff --git a/extensions/tn_attestation/canonical.go b/extensions/tn_attestation/canonical.go new file mode 100644 index 000000000..0329e8d42 --- /dev/null +++ b/extensions/tn_attestation/canonical.go @@ -0,0 +1,114 @@ +package tn_attestation + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" +) + +// CanonicalPayload represents the eight attestation fields stored in result_canonical. +// The byte layout mirrors the SQL migration: fixed-width integers followed by +// length-prefixed blobs (little-endian 4-byte prefixes for variable sections). +// +// Layout: +// +// 1 byte version +// 1 byte algorithm +// 8 bytes block height (big-endian) +// 4 + n data provider (length-prefixed) +// 4 + m stream ID (length-prefixed) +// 2 bytes action ID (big-endian) +// 4 + k arguments (length-prefixed) +// 4 + r result (length-prefixed) +type CanonicalPayload struct { + Version uint8 + Algorithm uint8 + BlockHeight uint64 + DataProvider []byte + StreamID []byte + ActionID uint16 + Args []byte + Result []byte + + raw []byte +} + +// ParseCanonicalPayload decodes the canonical payload into structured fields. +// The function validates every length prefix and returns descriptive errors so +// future maintainers can diagnose storage corruption quickly. +func ParseCanonicalPayload(data []byte) (*CanonicalPayload, error) { + if len(data) < 1+1+8+2 { + return nil, fmt.Errorf("canonical payload too short: got %d bytes", len(data)) + } + + cursor := 0 + payload := &CanonicalPayload{ + Version: data[cursor], + Algorithm: data[cursor+1], + } + cursor += 2 + + payload.BlockHeight = binary.BigEndian.Uint64(data[cursor : cursor+8]) + cursor += 8 + + var err error + if payload.DataProvider, cursor, err = readLengthPrefixed(data, cursor); err != nil { + return nil, fmt.Errorf("decode data_provider: %w", err) + } + if payload.StreamID, cursor, err = readLengthPrefixed(data, cursor); err != nil { + return nil, fmt.Errorf("decode stream_id: %w", err) + } + + if len(data) < cursor+2 { + return nil, fmt.Errorf("canonical payload truncated before action_id") + } + payload.ActionID = binary.BigEndian.Uint16(data[cursor : cursor+2]) + cursor += 2 + + if payload.Args, cursor, err = readLengthPrefixed(data, cursor); err != nil { + return nil, fmt.Errorf("decode args: %w", err) + } + if payload.Result, cursor, err = readLengthPrefixed(data, cursor); err != nil { + return nil, fmt.Errorf("decode result: %w", err) + } + + if cursor != len(data) { + return nil, fmt.Errorf("canonical payload has %d trailing bytes", len(data)-cursor) + } + + payload.raw = append(payload.raw[:0], data...) // ensure private copy + return payload, nil +} + +// SigningBytes returns the backing canonical bytes that must be covered by the +// validator's signature (fields 1 through 8). Callers should treat the slice as +// immutable. +func (p *CanonicalPayload) SigningBytes() []byte { + return p.raw +} + +// SigningDigest computes sha256(SigningBytes()) to match the on-chain verifier +// expectations. The digest is returned as a value to prevent accidental reuse of +// the backing slice. +func (p *CanonicalPayload) SigningDigest() [sha256.Size]byte { + return sha256.Sum256(p.SigningBytes()) +} + +// readLengthPrefixed decodes a little-endian uint32 length followed by that many bytes. +func readLengthPrefixed(data []byte, cursor int) ([]byte, int, error) { + if len(data) < cursor+4 { + return nil, cursor, fmt.Errorf("truncated length prefix at offset %d", cursor) + } + + length := binary.LittleEndian.Uint32(data[cursor : cursor+4]) + cursor += 4 + + if len(data) < cursor+int(length) { + return nil, cursor, fmt.Errorf("declared length %d exceeds remaining %d bytes", length, len(data)-cursor) + } + + chunk := data[cursor : cursor+int(length)] + cursor += int(length) + return bytes.Clone(chunk), cursor, nil +} diff --git a/extensions/tn_attestation/canonical_test.go b/extensions/tn_attestation/canonical_test.go new file mode 100644 index 000000000..b774c8863 --- /dev/null +++ b/extensions/tn_attestation/canonical_test.go @@ -0,0 +1,90 @@ +package tn_attestation + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCanonicalPayload_Success(t *testing.T) { + version := uint8(1) + algo := uint8(1) + height := uint64(12345) + actionID := uint16(9) + dataProvider := []byte("provider-1") + streamID := []byte("stream-xyz") + args := []byte{0x01, 0x02, 0x03} + result := []byte{0xAA, 0xBB} + + raw := buildCanonical(version, algo, height, dataProvider, streamID, actionID, args, result) + + payload, err := ParseCanonicalPayload(raw) + require.NoError(t, err) + require.NotNil(t, payload) + + require.Equal(t, version, payload.Version) + require.Equal(t, algo, payload.Algorithm) + require.Equal(t, height, payload.BlockHeight) + require.Equal(t, dataProvider, payload.DataProvider) + require.Equal(t, streamID, payload.StreamID) + require.Equal(t, actionID, payload.ActionID) + require.Equal(t, args, payload.Args) + require.Equal(t, result, payload.Result) + + // Signing digest should equal sha256(raw) + expectedDigest := sha256.Sum256(raw) + require.Equal(t, expectedDigest, payload.SigningDigest()) + require.True(t, bytes.Equal(raw, payload.SigningBytes())) +} + +func TestParseCanonicalPayload_TruncatedPrefix(t *testing.T) { + base := buildCanonical(1, 1, 1, []byte("a"), []byte("b"), 1, []byte{0x01}, []byte{0x02}) + // Corrupt by chopping last byte + corrupted := base[:len(base)-1] + + _, err := ParseCanonicalPayload(corrupted) + require.Error(t, err) + require.Contains(t, err.Error(), "decode result") +} + +func TestParseCanonicalPayload_ExtraBytes(t *testing.T) { + base := buildCanonical(1, 1, 1, []byte("a"), []byte("b"), 1, []byte{0x01}, []byte{0x02}) + extra := append(base, []byte{0xFF, 0xFF}...) + + _, err := ParseCanonicalPayload(extra) + require.Error(t, err) + require.Contains(t, err.Error(), "trailing bytes") +} + +// buildCanonical mirrors the SQL encoder to generate canonical payloads. +func buildCanonical(version, algo uint8, height uint64, provider, stream []byte, actionID uint16, args, result []byte) []byte { + buf := bytes.NewBuffer(nil) + buf.WriteByte(version) + buf.WriteByte(algo) + + heightBytes := make([]byte, 8) + binary.BigEndian.PutUint64(heightBytes, height) + buf.Write(heightBytes) + + writeLengthPrefixed(buf, provider) + writeLengthPrefixed(buf, stream) + + actionBytes := make([]byte, 2) + binary.BigEndian.PutUint16(actionBytes, actionID) + buf.Write(actionBytes) + + writeLengthPrefixed(buf, args) + writeLengthPrefixed(buf, result) + + return buf.Bytes() +} + +func writeLengthPrefixed(buf *bytes.Buffer, chunk []byte) { + lengthBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(lengthBytes, uint32(len(chunk))) + buf.Write(lengthBytes) + buf.Write(chunk) +} diff --git a/extensions/tn_attestation/extension.go b/extensions/tn_attestation/extension.go new file mode 100644 index 000000000..a9029f1c6 --- /dev/null +++ b/extensions/tn_attestation/extension.go @@ -0,0 +1,267 @@ +package tn_attestation + +import ( + "context" + "fmt" + "sync" + + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/core/crypto/auth" + "github.com/trufnetwork/kwil-db/core/log" + "github.com/trufnetwork/kwil-db/core/types" + sql "github.com/trufnetwork/kwil-db/node/types/sql" +) + +// signerExtension captures node-level wiring required for the attestation signer. +// The struct will evolve as we thread additional dependencies (engine, accounts, +// signer, broadcaster, etc.) through the extension during subsequent steps. +type signerExtension struct { + logger log.Logger + service *common.Service + + scanIntervalBlocks int64 + scanBatchLimit int64 + lastScanHeight int64 + isLeader bool + + engine common.Engine + db sql.DB + accounts common.Accounts + + broadcaster TxBroadcaster + nodeSigner auth.Signer + + processOverride func(context.Context, []string) + + mu sync.RWMutex +} + +var ( + extensionOnce sync.Once + extensionInst *signerExtension +) + +// getExtension returns the singleton instance, initialising it lazily so tests +// can replace or reset state as needed. +func getExtension() *signerExtension { + extensionOnce.Do(func() { + extensionInst = &signerExtension{ + logger: log.New(log.WithLevel(log.LevelInfo)).New(ExtensionName), + scanIntervalBlocks: 100, + scanBatchLimit: 100, + } + }) + return extensionInst +} + +// SetExtension allows tests to inject a pre-configured instance. +func SetExtension(ext *signerExtension) { + extensionInst = ext +} + +// Logger provides the extension logger, defaulting to a module-specific child of +// the global logger. +func (e *signerExtension) Logger() log.Logger { + e.mu.RLock() + defer e.mu.RUnlock() + return e.logger +} + +// Service retrieves the cached service pointer. The service includes configs, +// identity, and logger; storing it lets the extension re-use those resources +// outside hook invocations. +func (e *signerExtension) Service() *common.Service { + e.mu.RLock() + defer e.mu.RUnlock() + return e.service +} + +// setService captures the service and refreshes the module logger. +func (e *signerExtension) setService(svc *common.Service) { + e.mu.Lock() + defer e.mu.Unlock() + e.service = svc + if svc != nil && svc.Logger != nil { + e.logger = svc.Logger.New(ExtensionName) + } +} + +func (e *signerExtension) applyConfig(service *common.Service) { + if service == nil || service.LocalConfig == nil { + return + } + if cfg, ok := service.LocalConfig.Extensions[ExtensionName]; ok { + if v, ok := cfg["scan_interval_blocks"]; ok && v != "" { + if parsed, err := parsePositiveInt64(v); err == nil { + e.setScanIntervalBlocks(parsed) + } else { + e.Logger().Warn("invalid scan_interval_blocks; using default", "value", v, "error", err) + } + } + if v, ok := cfg["scan_batch_limit"]; ok && v != "" { + if parsed, err := parsePositiveInt64(v); err == nil { + e.setScanBatchLimit(parsed) + } else { + e.Logger().Warn("invalid scan_batch_limit; using default", "value", v, "error", err) + } + } + } +} + +func (e *signerExtension) setApp(app *common.App) { + e.mu.Lock() + defer e.mu.Unlock() + if app != nil { + e.engine = app.Engine + e.db = app.DB + e.accounts = app.Accounts + } +} + +func (e *signerExtension) Engine() common.Engine { + e.mu.RLock() + defer e.mu.RUnlock() + return e.engine +} + +func (e *signerExtension) DB() sql.DB { + e.mu.RLock() + defer e.mu.RUnlock() + return e.db +} + +func (e *signerExtension) Accounts() common.Accounts { + e.mu.RLock() + defer e.mu.RUnlock() + return e.accounts +} + +func (e *signerExtension) setBroadcaster(b TxBroadcaster) { + e.mu.Lock() + defer e.mu.Unlock() + e.broadcaster = b +} + +func (e *signerExtension) Broadcaster() TxBroadcaster { + e.mu.RLock() + defer e.mu.RUnlock() + return e.broadcaster +} + +func (e *signerExtension) setNodeSigner(s auth.Signer) { + e.mu.Lock() + defer e.mu.Unlock() + e.nodeSigner = s +} + +func (e *signerExtension) NodeSigner() auth.Signer { + e.mu.RLock() + defer e.mu.RUnlock() + return e.nodeSigner +} + +func (e *signerExtension) setProcessOverride(fn func(context.Context, []string)) { + e.mu.Lock() + defer e.mu.Unlock() + e.processOverride = fn +} + +func (e *signerExtension) setLeader(isLeader bool, height int64) { + e.mu.Lock() + defer e.mu.Unlock() + e.isLeader = isLeader + if isLeader && height > 0 { + e.lastScanHeight = height + } +} + +func (e *signerExtension) Leader() bool { + e.mu.RLock() + defer e.mu.RUnlock() + return e.isLeader +} + +func (e *signerExtension) setScanIntervalBlocks(v int64) { + e.mu.Lock() + defer e.mu.Unlock() + if v > 0 { + e.scanIntervalBlocks = v + } +} + +func (e *signerExtension) setScanBatchLimit(v int64) { + e.mu.Lock() + defer e.mu.Unlock() + if v > 0 { + e.scanBatchLimit = v + } +} + +func (e *signerExtension) ScanIntervalBlocks() int64 { + e.mu.RLock() + defer e.mu.RUnlock() + if e.scanIntervalBlocks <= 0 { + return 100 + } + return e.scanIntervalBlocks +} + +func (e *signerExtension) ScanBatchLimit() int64 { + e.mu.RLock() + defer e.mu.RUnlock() + if e.scanBatchLimit <= 0 { + return 100 + } + return e.scanBatchLimit +} + +func (e *signerExtension) recordScanHeight(height int64) { + e.mu.Lock() + defer e.mu.Unlock() + if height > e.lastScanHeight { + e.lastScanHeight = height + } +} + +func (e *signerExtension) LastScanHeight() int64 { + e.mu.RLock() + defer e.mu.RUnlock() + return e.lastScanHeight +} + +func (e *signerExtension) shouldPerformScan(height int64) bool { + interval := e.ScanIntervalBlocks() + if interval <= 0 { + return false + } + + last := e.LastScanHeight() + if last == 0 { + e.recordScanHeight(height) + return true + } + + if height-last >= interval { + e.recordScanHeight(height) + return true + } + return false +} + +func parsePositiveInt64(raw string) (int64, error) { + var v int64 + _, err := fmt.Sscan(raw, &v) + if err != nil { + return 0, err + } + if v <= 0 { + return 0, fmt.Errorf("value must be positive, got %d", v) + } + return v, nil +} + +// TxBroadcaster matches the subset of the JSON-RPC client used by the signing +// worker to inject transactions. +type TxBroadcaster interface { + BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) +} diff --git a/extensions/tn_attestation/harness_integration_test.go b/extensions/tn_attestation/harness_integration_test.go new file mode 100644 index 000000000..264caf199 --- /dev/null +++ b/extensions/tn_attestation/harness_integration_test.go @@ -0,0 +1,443 @@ +//go:build kwiltest + +package tn_attestation + +import ( + "context" + "encoding/hex" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/config" + kcrypto "github.com/trufnetwork/kwil-db/core/crypto" + "github.com/trufnetwork/kwil-db/core/crypto/auth" + "github.com/trufnetwork/kwil-db/core/log" + ktypes "github.com/trufnetwork/kwil-db/core/types" + extauth "github.com/trufnetwork/kwil-db/extensions/auth" + "github.com/trufnetwork/kwil-db/extensions/precompiles" + erc20shim "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" + orderedsync "github.com/trufnetwork/kwil-db/node/exts/ordered-sync" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + databasesize "github.com/trufnetwork/node/extensions/database-size" + "github.com/trufnetwork/node/extensions/tn_cache" + "github.com/trufnetwork/node/extensions/tn_utils" + "github.com/trufnetwork/node/internal/migrations" + "github.com/trufnetwork/sdk-go/core/util" +) + +func init() { + // Register extension precompiles for tests + err := precompiles.RegisterInitializer(tn_cache.ExtensionName, tn_cache.InitializeCachePrecompile) + if err != nil { + panic("failed to register tn_cache precompiles: " + err.Error()) + } + + err = precompiles.RegisterInitializer(databasesize.ExtensionName, databasesize.InitializeDatabaseSizePrecompile) + if err != nil { + panic("failed to register database_size precompiles: " + err.Error()) + } + + tn_utils.InitializeExtension() + InitializeExtension() +} + +func TestSigningWorkflowWithHarness(t *testing.T) { + // This is a high-level smoke test that exercises the SQL migration plus the Go + // extension working together. Rather than stubbing everything, we lean on the + // harness to spin up the database, run the real migration, and verify that an + // attestation request leaves a canonical row that our Go helpers understand. + const ( + testActionName = "harness_attestation_action" + testActionID = 21 + attestedValue = int64(9001) + ) + + // Reset extension singletons before test to avoid conflicts + orderedsync.ForTestingReset() + erc20shim.ForTestingResetSingleton() + erc20shim.ForTestingClearAllInstances(context.Background(), nil) + + ownerAddr := util.Unsafe_NewEthereumAddressFromString("0x0000000000000000000000000000000000000a22") + requesterAddrValue := util.Unsafe_NewEthereumAddressFromString("0xabc0000000000000000000000000000000000a22") + requesterAddr := &requesterAddrValue + + options := &kwilTesting.Options{ + UseTestContainer: true, + SetupMetaStore: true, + } + + kwilTesting.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "tn_attestation_signing_harness", + SeedScripts: migrations.GetSeedScriptPaths(), + Owner: ownerAddr.Address(), + FunctionTests: []kwilTesting.TestFunc{ + func(ctx context.Context, platform *kwilTesting.Platform) error { + platform.Deployer = ownerAddr.Bytes() + + // Provision a lightweight action and allowlist entry so the request + // mirrors production: the Go test intentionally exercises the exact SQL + // path that nodes run when users hit the public API. + require.NoError(t, setupTestAttestationAction(ctx, platform, testActionName, testActionID)) + + // Request the attestation through the live migration. This ensures the + // canonical payload we inspect later is produced by the SQL we ship. + dataProvider := []byte("provider-harness") + streamID := []byte("stream-harness") + argsBytes, err := tn_utils.EncodeActionArgs([]any{attestedValue}) + require.NoError(t, err) + + engineCtx := newHarnessEngineContext(ctx, platform, requesterAddr) + + var attestationHash []byte + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", []any{ + dataProvider, + streamID, + testActionName, + argsBytes, + false, + int64(0), + }, func(row *common.Row) error { + if len(row.Values) != 1 { + return fmt.Errorf("expected single return value, got %d", len(row.Values)) + } + hash, ok := row.Values[0].([]byte) + if !ok { + return fmt.Errorf("expected BYTEA return, got %T", row.Values[0]) + } + attestationHash = append([]byte(nil), hash...) + return nil + }) + require.NoError(t, err, "request_attestation failed") + require.NotEmpty(t, attestationHash, "request_attestation should return attestation hash") + + // At this point we expect a single row inserted into the persisted + // table. Fetch it back and validate every column so future changes that + // alter canonical layout or metadata will trip this test. + stored := fetchAttestationRowHarness(t, ctx, platform, attestationHash) + require.Equal(t, attestationHash, stored.attestationHash) + require.Equal(t, requesterAddr.Bytes(), stored.requester) + require.NotEmpty(t, stored.resultCanonical, "canonical payload should be stored") + require.False(t, stored.encryptSig, "encrypt_sig must be false in MVP") + require.Nil(t, stored.signature, "signature should be NULL before signing") + require.Nil(t, stored.validatorPubKey, "validator_pubkey should be NULL before signing") + require.Nil(t, stored.signedHeight, "signed_height should be NULL before signing") + + // The canonical blob should round-trip through the Go parser; we assert + // the critical fields so the SQL encoder and Go decoder stay in lockstep. + payload, err := ParseCanonicalPayload(stored.resultCanonical) + require.NoError(t, err, "canonical payload should be parseable") + require.Equal(t, uint8(1), payload.Version) + require.Equal(t, uint8(1), payload.Algorithm) + require.Equal(t, dataProvider, payload.DataProvider) + require.Equal(t, streamID, payload.StreamID) + require.Equal(t, uint16(testActionID), payload.ActionID) + require.Equal(t, argsBytes, payload.Args) + require.NotEmpty(t, payload.Result, "query result should be stored") + + // Finally ensure we can derive the digest that the signing service uses; + // downstream tests rely on this helper, and this assertion guarantees the + // canonical format remains stable. + digest := payload.SigningDigest() + require.Len(t, digest, 32, "digest should be 32 bytes (SHA-256)") + + // Phase 2: Prepare signing work - validator generates signature + privateKey, _, err := kcrypto.GenerateSecp256k1Key(nil) + require.NoError(t, err) + + ResetValidatorSignerForTesting() + t.Cleanup(ResetValidatorSignerForTesting) + require.NoError(t, InitializeValidatorSigner(privateKey)) + validatorSigner := GetValidatorSigner() + require.NotNil(t, validatorSigner) + + nodeSigner := auth.GetNodeSigner(privateKey) + require.NotNil(t, nodeSigner) + pubKey, ok := nodeSigner.PubKey().(*kcrypto.Secp256k1PublicKey) + require.True(t, ok, "unexpected validator pubkey type") + + // Setup extension with real dependencies + service := &common.Service{ + Logger: log.DiscardLogger, + GenesisConfig: &config.GenesisConfig{ChainID: "attestation-harness"}, + LocalConfig: &config.Config{}, + } + + ext := getExtension() + ext.setService(service) + ext.setApp(&common.App{ + Engine: platform.Engine, + DB: platform.DB, + Accounts: &signerAccountsStub{}, + Service: service, + }) + ext.setNodeSigner(nodeSigner) + + hashHex := hex.EncodeToString(attestationHash) + prepared, err := ext.prepareSigningWork(ctx, hashHex) + require.NoError(t, err) + require.Len(t, prepared, 1, "expected one prepared signature") + + // Verify signature was generated correctly + require.Equal(t, hashHex, prepared[0].HashHex) + require.Equal(t, attestationHash, prepared[0].Hash) + require.Equal(t, requesterAddr.Bytes(), prepared[0].Requester) + require.Len(t, prepared[0].Signature, 65, "EVM signature is 65 bytes") + require.Equal(t, stored.createdHeight, prepared[0].CreatedHeight) + + // Phase 3: Execute sign_attestation action directly with real migrations + const signHeight = int64(42) + + // Create context as the block leader for sign_attestation + signerAddr := kcrypto.EthereumAddressFromPubKey(pubKey) + caller, err := extauth.GetIdentifier(auth.EthPersonalSignAuth, signerAddr) + require.NoError(t, err) + + signTxCtx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{ + Height: signHeight, + Proposer: pubKey, + }, + Signer: signerAddr, + Caller: caller, + TxID: platform.Txid(), + Authenticator: auth.EthPersonalSignAuth, + } + + // Call sign_attestation action from real migrations + signEngCtx := &common.EngineContext{TxContext: signTxCtx} + res, err := platform.Engine.Call(signEngCtx, platform.DB, "", "sign_attestation", []any{ + attestationHash, + requesterAddr.Bytes(), + stored.createdHeight, + prepared[0].Signature, + }, func(*common.Row) error { return nil }) + require.NoError(t, err) + require.NotNil(t, res) + if res.Error != nil { + t.Fatalf("sign_attestation failed: %v", res.Error) + } + + // Verify signed state in database + signedRow := fetchAttestationRowHarness(t, ctx, platform, attestationHash) + require.NotNil(t, signedRow.signature, "signature should be recorded") + require.Equal(t, prepared[0].Signature, signedRow.signature) + require.NotNil(t, signedRow.validatorPubKey, "validator pubkey should be recorded") + // validator_pubkey is set to @signer which is the Ethereum address + require.Equal(t, signerAddr, signedRow.validatorPubKey) + require.NotNil(t, signedRow.signedHeight, "signed height should be recorded") + require.Equal(t, signHeight, *signedRow.signedHeight) + + return nil + }, + }, + }, options) +} + +// setupTestAttestationAction creates a test action and registers it in the attestation allowlist +func setupTestAttestationAction(ctx context.Context, platform *kwilTesting.Platform, actionName string, actionID int) error { + engineCtx := &common.EngineContext{ + TxContext: &common.TxContext{ + Ctx: ctx, + Signer: platform.Deployer, + Caller: string(platform.Deployer), + TxID: platform.Txid(), + BlockContext: &common.BlockContext{ + Height: 1, + }, + }, + OverrideAuthz: true, + } + + createAction := ` +CREATE OR REPLACE ACTION ` + actionName + `( + $value INT8 +) PUBLIC VIEW RETURNS TABLE(result INT8) { + RETURN NEXT $value; +};` + + if err := platform.Engine.Execute(engineCtx, platform.DB, createAction, nil, nil); err != nil { + return fmt.Errorf("create action: %w", err) + } + + insertAllowlist := ` +INSERT INTO attestation_actions(action_name, action_id) +VALUES ($action_name, $action_id) +ON CONFLICT (action_name) DO UPDATE SET action_id = EXCLUDED.action_id;` + + params := map[string]any{ + "action_name": actionName, + "action_id": actionID, + } + + if err := platform.Engine.Execute(engineCtx, platform.DB, insertAllowlist, params, nil); err != nil { + return fmt.Errorf("insert attestation action allowlist: %w", err) + } + + return nil +} + +func newHarnessEngineContext(ctx context.Context, platform *kwilTesting.Platform, requester *util.EthereumAddress) *common.EngineContext { + return &common.EngineContext{ + TxContext: &common.TxContext{ + Ctx: ctx, + Signer: requester.Bytes(), + Caller: requester.Address(), + TxID: platform.Txid(), + BlockContext: &common.BlockContext{ + Height: 1, + }, + }, + } +} + +func fetchAttestationRowHarness(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, hash []byte) harnessAttestationRow { + engineCtx := &common.EngineContext{ + TxContext: &common.TxContext{ + Ctx: ctx, + Signer: platform.Deployer, + Caller: string(platform.Deployer), + TxID: platform.Txid(), + BlockContext: &common.BlockContext{ + Height: 1, + }, + }, + OverrideAuthz: true, + } + + var rowData harnessAttestationRow + err := platform.Engine.Execute(engineCtx, platform.DB, ` +SELECT requester, attestation_hash, result_canonical, encrypt_sig, signature, validator_pubkey, signed_height, created_height +FROM attestations +WHERE attestation_hash = $hash; +`, map[string]any{"hash": hash}, func(row *common.Row) error { + rowData.requester = append([]byte(nil), row.Values[0].([]byte)...) + rowData.attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + rowData.resultCanonical = append([]byte(nil), row.Values[2].([]byte)...) + rowData.encryptSig = row.Values[3].(bool) + if row.Values[4] != nil { + rowData.signature = append([]byte(nil), row.Values[4].([]byte)...) + } + if row.Values[5] != nil { + rowData.validatorPubKey = append([]byte(nil), row.Values[5].([]byte)...) + } + if row.Values[6] != nil { + height := row.Values[6].(int64) + rowData.signedHeight = &height + } + rowData.createdHeight = row.Values[7].(int64) + return nil + }) + require.NoError(t, err) + return rowData +} + +type harnessAttestationRow struct { + requester []byte + attestationHash []byte + resultCanonical []byte + encryptSig bool + signature []byte + validatorPubKey []byte + signedHeight *int64 + createdHeight int64 +} + +type harnessExecutingBroadcaster struct { + t *testing.T + platform *kwilTesting.Platform + pubKey *kcrypto.Secp256k1PublicKey + nodeSigner auth.Signer + signHeight int64 + calls int +} + +func (b *harnessExecutingBroadcaster) BroadcastTx(ctx context.Context, tx *ktypes.Transaction, sync uint8) (ktypes.Hash, *ktypes.TxResult, error) { + b.calls++ + + // Parse transaction payload + payload := new(ktypes.ActionExecution) + if err := payload.UnmarshalBinary(tx.Body.Payload); err != nil { + return ktypes.Hash{}, nil, err + } + + require.Equal(b.t, "sign_attestation", payload.Action) + require.Len(b.t, payload.Arguments, 1) + require.Len(b.t, payload.Arguments[0], 4) + + // Decode arguments + hashBytes := b.decodeByteArg(payload.Arguments[0][0]) + requesterBytes := b.decodeByteArg(payload.Arguments[0][1]) + createdHeight := b.decodeInt64Arg(payload.Arguments[0][2]) + sigBytes := b.decodeByteArg(payload.Arguments[0][3]) + + // Get caller identifier for leader check + // For leader authorization to work, Signer must be the Ethereum address derived from the proposer's public key + signerAddr := kcrypto.EthereumAddressFromPubKey(b.pubKey) + caller, err := auth.GetNodeIdentifier(b.pubKey) + require.NoError(b.t, err) + + // Create engine context with leader as proposer + txCtx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{ + Height: b.signHeight, + Proposer: b.pubKey, + }, + Signer: signerAddr, + Caller: caller, + TxID: b.platform.Txid(), + Authenticator: auth.EthPersonalSignAuth, + } + + // Execute real sign_attestation action from migrations + res, err := b.platform.Engine.Call( + &common.EngineContext{TxContext: txCtx}, + b.platform.DB, + "", + "sign_attestation", + []any{hashBytes, requesterBytes, createdHeight, sigBytes}, + func(*common.Row) error { return nil }, + ) + + require.NoError(b.t, err) + require.NotNil(b.t, res) + if res.Error != nil { + b.t.Fatalf("sign_attestation failed: %v", res.Error) + } + + return ktypes.Hash{}, &ktypes.TxResult{Code: uint32(ktypes.CodeOk)}, nil +} + +func (b *harnessExecutingBroadcaster) decodeByteArg(arg *ktypes.EncodedValue) []byte { + val, err := arg.Decode() + require.NoError(b.t, err) + switch typed := val.(type) { + case []byte: + return typed + case *[]byte: + require.NotNil(b.t, typed) + return *typed + default: + b.t.Fatalf("unexpected byte arg type %T", val) + return nil + } +} + +func (b *harnessExecutingBroadcaster) decodeInt64Arg(arg *ktypes.EncodedValue) int64 { + val, err := arg.Decode() + require.NoError(b.t, err) + switch typed := val.(type) { + case int64: + return typed + case *int64: + require.NotNil(b.t, typed) + return *typed + default: + b.t.Fatalf("unexpected int64 arg type %T", val) + return 0 + } +} diff --git a/extensions/tn_attestation/integration_test.go b/extensions/tn_attestation/integration_test.go new file mode 100644 index 000000000..ec1ae9660 --- /dev/null +++ b/extensions/tn_attestation/integration_test.go @@ -0,0 +1,363 @@ +package tn_attestation + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math/big" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/config" + kcrypto "github.com/trufnetwork/kwil-db/core/crypto" + "github.com/trufnetwork/kwil-db/core/crypto/auth" + "github.com/trufnetwork/kwil-db/core/log" + ktypes "github.com/trufnetwork/kwil-db/core/types" + "github.com/trufnetwork/kwil-db/node/types/sql" +) + +func TestSigningWorkflowIntegration(t *testing.T) { + t.Helper() + + t.Run("QueuePath", func(t *testing.T) { + runSigningIntegration(t, true) + }) + + t.Run("FallbackPath", func(t *testing.T) { + runSigningIntegration(t, false) + }) +} + +func runSigningIntegration(t *testing.T, useQueue bool) { + t.Helper() + + resetIntegrationState() + + privateKey, publicKey, err := kcrypto.GenerateSecp256k1Key(nil) + require.NoError(t, err) + require.NoError(t, InitializeValidatorSigner(privateKey)) + defer ResetValidatorSignerForTesting() + + // Canonical payload mirrors the SQL construction to exercise the full pipeline. + version := uint8(1) + algo := uint8(1) + height := uint64(77) + actionID := uint16(5) + dataProvider := []byte("provider-queue-flow") + streamID := []byte("stream-queue-flow") + args := []byte{0x01, 0x02} + result := []byte{0x03} + + canonical := buildCanonicalPayload(version, algo, height, dataProvider, streamID, actionID, args, result) + payload, err := ParseCanonicalPayload(canonical) + require.NoError(t, err) + + hash := computeAttestationHash(payload) + hashHex := hex.EncodeToString(hash[:]) + requester := []byte("requester-1") + + engine := &integrationEngineStub{ + rows: []*common.Row{ + { + Values: []any{ + hash[:], + requester, + canonical, + int64(123), + }, + }, + }, + hashRows: []*common.Row{ + {Values: []any{hashHex}}, + }, + } + + ext := getExtension() + ext.logger = log.DiscardLogger + ext.service = &common.Service{ + Logger: log.DiscardLogger, + Identity: publicKey.Bytes(), + GenesisConfig: &config.GenesisConfig{ + ChainID: "integration-test-chain", + }, + } + ext.engine = engine + ext.db = integrationDBStub{} + ext.accounts = &signerAccountsStub{} + ext.scanIntervalBlocks = 1 + ext.scanBatchLimit = 10 + ext.nodeSigner = auth.GetNodeSigner(privateKey) + + broadcaster := &captureBroadcaster{} + ext.broadcaster = broadcaster + + queue := GetAttestationQueue() + queue.Clear() + if useQueue { + queue.Enqueue(hashHex) + } + + ctx := context.Background() + records, err := ext.fetchUnsignedAttestations(ctx, hash[:]) + require.NoError(t, err) + require.Truef(t, engine.served, "engine.ExecuteWithoutEngineCtx was not invoked (statement=%s)", engine.lastStmt) + require.Lenf(t, records, 1, "fetchUnsignedAttestations returned no rows (statement=%s)", engine.lastStmt) + + prepared, err := ext.prepareSigningWork(ctx, hashHex) + require.NoError(t, err) + require.Len(t, prepared, 1, "expected signing work to be prepared") + + if useQueue { + ext.processAttestationHashes(ctx, []string{hashHex}) + } else { + hashes, err := ext.fetchPendingHashes(ctx, 10) + require.NoError(t, err) + require.Equal(t, []string{hashHex}, hashes) + ext.processAttestationHashes(ctx, hashes) + } + + require.Equal(t, 1, broadcaster.calls, "expected single broadcast") + require.NoError(t, broadcaster.lastErr) + require.Len(t, broadcaster.hashes, 1) + require.Equal(t, hashHex, broadcaster.hashes[0]) + require.Len(t, broadcaster.heights, 1) + require.Equal(t, int64(123), broadcaster.heights[0]) + require.Len(t, broadcaster.signatures, 1) + require.Len(t, broadcaster.signatures[0], 65, "expected 65-byte signature") +} + +func resetIntegrationState() { + extensionOnce = sync.Once{} + SetExtension(nil) + queueOnce = sync.Once{} + attestationQueueSingleton = nil +} + +type captureBroadcaster struct { + hashes []string + signatures [][]byte + heights []int64 + calls int + lastErr error +} + +func (b *captureBroadcaster) BroadcastTx(ctx context.Context, tx *ktypes.Transaction, sync uint8) (ktypes.Hash, *ktypes.TxResult, error) { + b.calls++ + + payload := new(ktypes.ActionExecution) + if err := payload.UnmarshalBinary(tx.Body.Payload); err != nil { + b.lastErr = err + return ktypes.Hash{}, nil, err + } + + if len(payload.Arguments) == 0 || len(payload.Arguments[0]) != 4 { + err := fmt.Errorf("unexpected argument shape") + b.lastErr = err + return ktypes.Hash{}, nil, err + } + + val, err := payload.Arguments[0][0].Decode() + if err != nil { + b.lastErr = err + return ktypes.Hash{}, nil, err + } + var hashBytes []byte + switch typed := val.(type) { + case []byte: + hashBytes = typed + case *[]byte: + if typed == nil { + err := fmt.Errorf("hash argument was null") + b.lastErr = err + return ktypes.Hash{}, nil, err + } + hashBytes = *typed + default: + err := fmt.Errorf("hash argument type %T", val) + b.lastErr = err + return ktypes.Hash{}, nil, err + } + b.hashes = append(b.hashes, hex.EncodeToString(hashBytes)) + + heightVal, err := payload.Arguments[0][2].Decode() + if err != nil { + b.lastErr = err + return ktypes.Hash{}, nil, err + } + var createdHeight int64 + switch typed := heightVal.(type) { + case int64: + createdHeight = typed + case *int64: + if typed == nil { + err := fmt.Errorf("created_height argument was null") + b.lastErr = err + return ktypes.Hash{}, nil, err + } + createdHeight = *typed + default: + err := fmt.Errorf("created_height argument type %T", heightVal) + b.lastErr = err + return ktypes.Hash{}, nil, err + } + b.heights = append(b.heights, createdHeight) + + sigVal, err := payload.Arguments[0][3].Decode() + if err != nil { + b.lastErr = err + return ktypes.Hash{}, nil, err + } + var sigBytes []byte + switch typed := sigVal.(type) { + case []byte: + sigBytes = typed + case *[]byte: + if typed == nil { + err := fmt.Errorf("signature argument was null") + b.lastErr = err + return ktypes.Hash{}, nil, err + } + sigBytes = *typed + default: + err := fmt.Errorf("signature argument type %T", sigVal) + b.lastErr = err + return ktypes.Hash{}, nil, err + } + b.signatures = append(b.signatures, bytes.Clone(sigBytes)) + + return ktypes.Hash{}, &ktypes.TxResult{Code: uint32(ktypes.CodeOk)}, nil +} + +type integrationEngineStub struct { + rows []*common.Row + hashRows []*common.Row + lastStmt string + served bool +} + +func (s *integrationEngineStub) Call(*common.EngineContext, sql.DB, string, string, []any, func(*common.Row) error) (*common.CallResult, error) { + panic("Call not implemented") +} + +func (s *integrationEngineStub) CallWithoutEngineCtx(context.Context, sql.DB, string, string, []any, func(*common.Row) error) (*common.CallResult, error) { + panic("CallWithoutEngineCtx not implemented") +} + +func (s *integrationEngineStub) Execute(*common.EngineContext, sql.DB, string, map[string]any, func(*common.Row) error) error { + panic("Execute not implemented") +} + +func (s *integrationEngineStub) ExecuteWithoutEngineCtx(ctx context.Context, db sql.DB, statement string, params map[string]any, fn func(*common.Row) error) error { + s.lastStmt = statement + if strings.Contains(statement, "GROUP BY attestation_hash") { + for _, row := range s.hashRows { + if err := fn(row); err != nil { + return err + } + } + return nil + } + for _, row := range s.rows { + s.served = true + if err := fn(row); err != nil { + return err + } + } + return nil +} + +type integrationDBStub struct{} + +func (integrationDBStub) Execute(context.Context, string, ...any) (*sql.ResultSet, error) { + return nil, nil +} + +func (integrationDBStub) BeginTx(context.Context) (sql.Tx, error) { + return nil, fmt.Errorf("transactions not supported in stub") +} + +type signerAccountsStub struct{} + +func (signerAccountsStub) Credit(context.Context, sql.Executor, *ktypes.AccountID, *big.Int) error { + return nil +} + +func (signerAccountsStub) Transfer(context.Context, sql.TxMaker, *ktypes.AccountID, *ktypes.AccountID, *big.Int) error { + return nil +} + +func (signerAccountsStub) GetAccount(context.Context, sql.Executor, *ktypes.AccountID) (*ktypes.Account, error) { + return nil, fmt.Errorf("not found") +} + +func (signerAccountsStub) ApplySpend(context.Context, sql.Executor, *ktypes.AccountID, *big.Int, int64) error { + return nil +} + +func buildCanonicalPayload(version, algo uint8, blockHeight uint64, dataProvider, streamID []byte, actionID uint16, args, result []byte) []byte { + versionBytes := []byte{version} + algoBytes := []byte{algo} + + heightBytes := make([]byte, 8) + binaryBigEndianPutUint64(heightBytes, blockHeight) + + actionBytes := make([]byte, 2) + binaryBigEndianPutUint16(actionBytes, actionID) + + segments := [][]byte{ + versionBytes, + algoBytes, + heightBytes, + lengthPrefixLittleEndian(dataProvider), + lengthPrefixLittleEndian(streamID), + actionBytes, + lengthPrefixLittleEndian(args), + lengthPrefixLittleEndian(result), + } + + var buf bytes.Buffer + for _, seg := range segments { + buf.Write(seg) + } + return buf.Bytes() +} + +func lengthPrefixLittleEndian(data []byte) []byte { + if data == nil { + data = []byte{} + } + prefixed := make([]byte, 4+len(data)) + binaryLittleEndianPutUint32(prefixed[:4], uint32(len(data))) + copy(prefixed[4:], data) + return prefixed +} + +func binaryBigEndianPutUint64(b []byte, v uint64) { + _ = b[7] + b[0] = byte(v >> 56) + b[1] = byte(v >> 48) + b[2] = byte(v >> 40) + b[3] = byte(v >> 32) + b[4] = byte(v >> 24) + b[5] = byte(v >> 16) + b[6] = byte(v >> 8) + b[7] = byte(v) +} + +func binaryBigEndianPutUint16(b []byte, v uint16) { + _ = b[1] + b[0] = byte(v >> 8) + b[1] = byte(v) +} + +func binaryLittleEndianPutUint32(b []byte, v uint32) { + _ = b[3] + b[0] = byte(v) + b[1] = byte(v >> 8) + b[2] = byte(v >> 16) + b[3] = byte(v >> 24) +} diff --git a/extensions/tn_attestation/precompile.go b/extensions/tn_attestation/precompile.go index a4e3811af..5286c8519 100644 --- a/extensions/tn_attestation/precompile.go +++ b/extensions/tn_attestation/precompile.go @@ -15,7 +15,7 @@ func registerPrecompile() error { return precompiles.RegisterPrecompile(ExtensionName, precompiles.Precompile{ // No cache needed: this precompile only affects leader's in-memory state, // which is intentionally non-deterministic. All validators return nil (deterministic). - Cache: nil, + Cache: nil, Methods: []precompiles.Method{ { Name: "queue_for_signing", diff --git a/extensions/tn_attestation/processor.go b/extensions/tn_attestation/processor.go new file mode 100644 index 000000000..b1f7d1ce6 --- /dev/null +++ b/extensions/tn_attestation/processor.go @@ -0,0 +1,193 @@ +package tn_attestation + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "strings" + + "github.com/trufnetwork/kwil-db/common" +) + +type attestationRecord struct { + hash []byte + requester []byte + canonical []byte + createdHeight int64 +} + +// PreparedSignature captures the data needed to call sign_attestation once +// broadcasting is wired: the attestation hash, the generated signature, and +// metadata for logging and auditing. +type PreparedSignature struct { + HashHex string + Hash []byte + Requester []byte + Signature []byte + Payload *CanonicalPayload + CreatedHeight int64 +} + +func (e *signerExtension) fetchUnsignedAttestations(ctx context.Context, hash []byte) ([]attestationRecord, error) { + engine := e.Engine() + db := e.DB() + if engine == nil || db == nil { + return nil, fmt.Errorf("attestation extension not initialised with engine/db") + } + + records := []attestationRecord{} + err := engine.ExecuteWithoutEngineCtx( + ctx, + db, + `SELECT attestation_hash, requester, result_canonical, created_height + FROM attestations + WHERE attestation_hash = $hash AND signature IS NULL + ORDER BY created_height ASC`, + map[string]any{"hash": hash}, + func(row *common.Row) error { + if len(row.Values) < 4 { + return fmt.Errorf("unexpected attestation row format: got %d columns", len(row.Values)) + } + + rec := attestationRecord{ + hash: bytesClone(row.Values[0].([]byte)), + requester: bytesCloneOrNil(row.Values[1]), + canonical: bytesClone(row.Values[2].([]byte)), + createdHeight: row.Values[3].(int64), + } + records = append(records, rec) + return nil + }, + ) + if err != nil { + return nil, err + } + + return records, nil +} + +func (e *signerExtension) prepareSigningWork(ctx context.Context, hashHex string) ([]*PreparedSignature, error) { + hashHex = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(hashHex)), "0x") + if hashHex == "" { + return nil, fmt.Errorf("attestation hash cannot be empty") + } + + hashBytes, err := hex.DecodeString(hashHex) + if err != nil { + return nil, fmt.Errorf("invalid attestation hash %q: %w", hashHex, err) + } + + records, err := e.fetchUnsignedAttestations(ctx, hashBytes) + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, nil + } + + signer := GetValidatorSigner() + if signer == nil { + return nil, fmt.Errorf("validator signer not initialised") + } + + prepared := make([]*PreparedSignature, 0, len(records)) + for _, rec := range records { + payload, err := ParseCanonicalPayload(rec.canonical) + if err != nil { + return nil, fmt.Errorf("parse canonical payload: %w", err) + } + + expectedHash := computeAttestationHash(payload) + if !bytes.Equal(expectedHash[:], rec.hash) { + return nil, fmt.Errorf("attestation hash mismatch: expected %x, got %x", rec.hash, expectedHash) + } + + digest := payload.SigningDigest() + signature, err := signer.SignDigest(digest[:]) + if err != nil { + return nil, fmt.Errorf("sign digest: %w", err) + } + + prepared = append(prepared, &PreparedSignature{ + HashHex: hashHex, + Hash: bytesClone(rec.hash), + Requester: bytesCloneOrNil(rec.requester), + Signature: signature, + Payload: payload, + CreatedHeight: rec.createdHeight, + }) + } + + return prepared, nil +} + +func (e *signerExtension) fetchPendingHashes(ctx context.Context, limit int) ([]string, error) { + engine := e.Engine() + db := e.DB() + if engine == nil || db == nil { + return nil, fmt.Errorf("attestation extension not initialised with engine/db") + } + if limit <= 0 { + limit = int(e.ScanBatchLimit()) + } + + hashes := make([]string, 0, limit) + err := engine.ExecuteWithoutEngineCtx( + ctx, + db, + `SELECT encode(attestation_hash, 'hex') AS hash + FROM attestations + WHERE signature IS NULL + GROUP BY attestation_hash + ORDER BY MIN(created_height) ASC + LIMIT $limit`, + map[string]any{"limit": limit}, + func(row *common.Row) error { + if len(row.Values) == 0 { + return nil + } + hash, ok := row.Values[0].(string) + if !ok { + return fmt.Errorf("unexpected hash column type %T", row.Values[0]) + } + hash = strings.TrimSpace(hash) + if hash != "" { + hashes = append(hashes, hash) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return hashes, nil +} + +func computeAttestationHash(p *CanonicalPayload) [sha256.Size]byte { + var buf bytes.Buffer + buf.WriteByte(p.Version) + buf.WriteByte(p.Algorithm) + buf.Write(p.DataProvider) + buf.Write(p.StreamID) + + actionBytes := make([]byte, 2) + binary.BigEndian.PutUint16(actionBytes, p.ActionID) + buf.Write(actionBytes) + buf.Write(p.Args) + + return sha256.Sum256(buf.Bytes()) +} + +func bytesClone(b []byte) []byte { + return bytes.Clone(b) +} + +func bytesCloneOrNil(v any) []byte { + if v == nil { + return nil + } + return bytes.Clone(v.([]byte)) +} diff --git a/extensions/tn_attestation/processor_test.go b/extensions/tn_attestation/processor_test.go new file mode 100644 index 000000000..a9e8fbaa5 --- /dev/null +++ b/extensions/tn_attestation/processor_test.go @@ -0,0 +1,369 @@ +package tn_attestation + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/config" + kwilcrypto "github.com/trufnetwork/kwil-db/core/crypto" + "github.com/trufnetwork/kwil-db/core/crypto/auth" + "github.com/trufnetwork/kwil-db/core/log" + ktypes "github.com/trufnetwork/kwil-db/core/types" + nodesql "github.com/trufnetwork/kwil-db/node/types/sql" +) + +func TestComputeAttestationHash(t *testing.T) { + payload := &CanonicalPayload{ + Version: 1, + Algorithm: 1, + DataProvider: []byte("provider"), + StreamID: []byte("stream"), + ActionID: 42, + Args: []byte{0x01, 0x02}, + } + var buf bytes.Buffer + buf.WriteByte(payload.Version) + buf.WriteByte(payload.Algorithm) + buf.Write(payload.DataProvider) + buf.Write(payload.StreamID) + actionBytes := make([]byte, 2) + binary.BigEndian.PutUint16(actionBytes, payload.ActionID) + buf.Write(actionBytes) + buf.Write(payload.Args) + expected := sha256.Sum256(buf.Bytes()) + + actual := computeAttestationHash(payload) + assert.Equal(t, expected, actual) +} + +func TestPrepareSigningWork(t *testing.T) { + t.Cleanup(ResetValidatorSignerForTesting) + + privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) + require.NoError(t, err) + require.NoError(t, InitializeValidatorSigner(privateKey)) + + version := uint8(1) + algo := uint8(1) + height := uint64(77) + actionID := uint16(5) + dataProvider := []byte("provider-1") + streamID := []byte("stream-abc") + args := []byte{0x01, 0x02} + result := []byte{0xAA} + + canonical := buildCanonical(version, algo, height, dataProvider, streamID, actionID, args, result) + payload, err := ParseCanonicalPayload(canonical) + require.NoError(t, err) + + hash := computeAttestationHash(payload) + + engine := &stubEngine{ + rows: []*common.Row{ + { + Values: []any{ + hash[:], + []byte("requester"), + canonical, + int64(123), + }, + }, + }, + } + + ext := &signerExtension{ + logger: log.DiscardLogger, + scanIntervalBlocks: 100, + } + ext.setApp(&common.App{ + Engine: engine, + DB: stubDB{}, + }) + + prepared, err := ext.prepareSigningWork(context.Background(), hex.EncodeToString(hash[:])) + require.NoError(t, err) + require.Len(t, prepared, 1) + + ps := prepared[0] + assert.Equal(t, hash[:], ps.Hash) + assert.Equal(t, payload, ps.Payload) + assert.Equal(t, int64(123), ps.CreatedHeight) + assert.Len(t, ps.Signature, 65) +} + +func TestSubmitSignature(t *testing.T) { + t.Cleanup(ResetValidatorSignerForTesting) + + privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) + require.NoError(t, err) + require.NoError(t, InitializeValidatorSigner(privateKey)) + + version := uint8(1) + algo := uint8(1) + height := uint64(77) + actionID := uint16(5) + dataProvider := []byte("provider-1") + streamID := []byte("stream-abc") + args := []byte{0x01, 0x02} + result := []byte{0xAA} + + canonical := buildCanonical(version, algo, height, dataProvider, streamID, actionID, args, result) + payload, err := ParseCanonicalPayload(canonical) + require.NoError(t, err) + + hash := computeAttestationHash(payload) + + engine := &stubEngine{ + rows: []*common.Row{ + { + Values: []any{ + hash[:], + []byte("requester"), + canonical, + int64(123), + }, + }, + }, + } + + service := &common.Service{ + Logger: log.DiscardLogger, + GenesisConfig: &config.GenesisConfig{ChainID: "test-chain"}, + LocalConfig: &config.Config{}, + } + + accounts := &stubAccounts{ + acct: &ktypes.Account{Nonce: 7}, + } + + broadcaster := &recordingBroadcaster{} + + ext := &signerExtension{ + logger: log.DiscardLogger, + scanIntervalBlocks: 100, + } + ext.setService(service) + ext.setApp(&common.App{ + Engine: engine, + DB: stubDB{}, + Accounts: accounts, + Service: service, + }) + ext.setNodeSigner(auth.GetNodeSigner(privateKey)) + ext.setBroadcaster(broadcaster) + + prepared, err := ext.prepareSigningWork(context.Background(), hex.EncodeToString(hash[:])) + require.NoError(t, err) + require.Len(t, prepared, 1) + + err = ext.submitSignature(context.Background(), prepared[0]) + require.NoError(t, err) + + assert.Equal(t, 1, broadcaster.calls) + require.NotNil(t, broadcaster.lastTx) + var decoded ktypes.ActionExecution + require.NoError(t, decoded.UnmarshalBinary(broadcaster.lastTx.Body.Payload)) + assert.Equal(t, "main", decoded.Namespace) + assert.Equal(t, "sign_attestation", decoded.Action) + require.Len(t, decoded.Arguments, 1) + require.Len(t, decoded.Arguments[0], 4) + + hashVal, err := decoded.Arguments[0][0].Decode() + require.NoError(t, err) + var hashBytes []byte + switch typed := hashVal.(type) { + case []byte: + hashBytes = typed + case *[]byte: + require.NotNil(t, typed, "hash argument pointer was nil") + hashBytes = *typed + default: + t.Fatalf("unexpected hash argument type %T", hashVal) + } + assert.Equal(t, hash[:], hashBytes) + + requesterVal, err := decoded.Arguments[0][1].Decode() + require.NoError(t, err) + var requesterBytes []byte + switch typed := requesterVal.(type) { + case []byte: + requesterBytes = typed + case *[]byte: + require.NotNil(t, typed, "requester argument pointer was nil") + requesterBytes = *typed + default: + t.Fatalf("unexpected requester argument type %T", requesterVal) + } + assert.Equal(t, []byte("requester"), requesterBytes) + + heightVal, err := decoded.Arguments[0][2].Decode() + require.NoError(t, err) + var createdHeight int64 + switch typed := heightVal.(type) { + case int64: + createdHeight = typed + case *int64: + require.NotNil(t, typed, "created_height argument pointer was nil") + createdHeight = *typed + default: + t.Fatalf("unexpected created_height argument type %T", heightVal) + } + assert.Equal(t, int64(123), createdHeight) + + sigVal, err := decoded.Arguments[0][3].Decode() + require.NoError(t, err) + var signatureBytes []byte + switch typed := sigVal.(type) { + case []byte: + signatureBytes = typed + case *[]byte: + require.NotNil(t, typed, "signature argument pointer was nil") + signatureBytes = *typed + default: + t.Fatalf("unexpected signature argument type %T", sigVal) + } + assert.Len(t, signatureBytes, 65) +} + +func TestFetchPendingHashes(t *testing.T) { + ext := &signerExtension{ + logger: log.DiscardLogger, + scanIntervalBlocks: 100, + scanBatchLimit: 5, + } + + engine := &stubEngine{ + hashRows: []*common.Row{ + {Values: []any{"aaa"}}, + {Values: []any{"bbb"}}, + {Values: []any{"ccc"}}, + }, + } + + ext.setApp(&common.App{ + Engine: engine, + DB: stubDB{}, + }) + + hashes, err := ext.fetchPendingHashes(context.Background(), 2) + require.NoError(t, err) + assert.Equal(t, []string{"aaa", "bbb"}, hashes) + + hashes, err = ext.fetchPendingHashes(context.Background(), 0) + require.NoError(t, err) + assert.Equal(t, []string{"aaa", "bbb", "ccc"}, hashes) +} + +type stubEngine struct { + rows []*common.Row + hashRows []*common.Row +} + +func (s *stubEngine) Call(ctx *common.EngineContext, db nodesql.DB, namespace, action string, args []any, resultFn func(*common.Row) error) (*common.CallResult, error) { + panic("not implemented") +} + +func (s *stubEngine) CallWithoutEngineCtx(ctx context.Context, db nodesql.DB, namespace, action string, args []any, resultFn func(*common.Row) error) (*common.CallResult, error) { + panic("not implemented") +} + +func (s *stubEngine) Execute(ctx *common.EngineContext, db nodesql.DB, statement string, params map[string]any, fn func(*common.Row) error) error { + panic("not implemented") +} + +func (s *stubEngine) ExecuteWithoutEngineCtx(ctx context.Context, db nodesql.DB, statement string, params map[string]any, fn func(*common.Row) error) error { + var rows []*common.Row + if strings.Contains(statement, "GROUP BY attestation_hash") { + rows = s.hashRows + if limit, ok := params["limit"]; ok { + if n := toInt(limit); n >= 0 && n < len(rows) { + rows = rows[:n] + } + } + } else { + rows = s.rows + } + for _, row := range rows { + if err := fn(row); err != nil { + return err + } + } + return nil +} + +type stubDB struct{} + +func (stubDB) Execute(ctx context.Context, stmt string, args ...any) (*nodesql.ResultSet, error) { + return nil, fmt.Errorf("not implemented") +} + +func (stubDB) BeginTx(ctx context.Context) (nodesql.Tx, error) { + return nil, fmt.Errorf("not implemented") +} + +func toInt(v any) int { + switch val := v.(type) { + case int: + return val + case int32: + return int(val) + case int64: + return int(val) + case uint: + return int(val) + case uint32: + return int(val) + case uint64: + return int(val) + default: + return -1 + } +} + +type stubAccounts struct { + acct *ktypes.Account + err error +} + +func (s *stubAccounts) Credit(ctx context.Context, tx nodesql.Executor, account *ktypes.AccountID, balance *big.Int) error { + return nil +} + +func (s *stubAccounts) Transfer(ctx context.Context, tx nodesql.TxMaker, from, to *ktypes.AccountID, amt *big.Int) error { + return nil +} + +func (s *stubAccounts) GetAccount(ctx context.Context, tx nodesql.Executor, account *ktypes.AccountID) (*ktypes.Account, error) { + if s.err != nil { + return nil, s.err + } + if s.acct != nil { + return s.acct, nil + } + return nil, fmt.Errorf("account not found") +} + +func (s *stubAccounts) ApplySpend(ctx context.Context, tx nodesql.Executor, account *ktypes.AccountID, amount *big.Int, nonce int64) error { + return nil +} + +type recordingBroadcaster struct { + calls int + lastTx *ktypes.Transaction +} + +func (b *recordingBroadcaster) BroadcastTx(ctx context.Context, tx *ktypes.Transaction, sync uint8) (ktypes.Hash, *ktypes.TxResult, error) { + b.calls++ + b.lastTx = tx + return ktypes.Hash{}, &ktypes.TxResult{Code: uint32(ktypes.CodeOk)}, nil +} diff --git a/extensions/tn_attestation/signer.go b/extensions/tn_attestation/signer.go index 8caeec779..e7af096d3 100644 --- a/extensions/tn_attestation/signer.go +++ b/extensions/tn_attestation/signer.go @@ -42,12 +42,8 @@ func NewValidatorSigner(privateKey kwilcrypto.PrivateKey) (*ValidatorSigner, err }, nil } -// SignKeccak256 signs the keccak256 hash of the payload and returns a 65-byte EVM-compatible signature. -// The signature format is [R || S || V] where: -// - R: 32 bytes (signature R component) -// - S: 32 bytes (signature S component) -// - V: 1 byte (recovery ID, 27 or 28 for EVM compatibility) -func (s *ValidatorSigner) SignKeccak256(payload []byte) ([]byte, error) { +// SignDigest signs the provided 32-byte digest (already hashed) and returns a 65-byte EVM-compatible signature. +func (s *ValidatorSigner) SignDigest(digest []byte) ([]byte, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -55,6 +51,26 @@ func (s *ValidatorSigner) SignKeccak256(payload []byte) ([]byte, error) { return nil, fmt.Errorf("private key not initialized") } + if len(digest) != crypto.DigestLength { + return nil, fmt.Errorf("digest must be %d bytes, got %d", crypto.DigestLength, len(digest)) + } + + signature, err := crypto.Sign(digest, s.privateKey) + if err != nil { + return nil, fmt.Errorf("failed to sign digest: %w", err) + } + + // Convert V from {0,1} to {27,28} for EVM compatibility. + signature[64] += 27 + return signature, nil +} + +// SignKeccak256 signs the keccak256 hash of the payload and returns a 65-byte EVM-compatible signature. +// The signature format is [R || S || V] where: +// - R: 32 bytes (signature R component) +// - S: 32 bytes (signature S component) +// - V: 1 byte (recovery ID, 27 or 28 for EVM compatibility) +func (s *ValidatorSigner) SignKeccak256(payload []byte) ([]byte, error) { if len(payload) == 0 { return nil, fmt.Errorf("payload cannot be empty") } @@ -63,17 +79,7 @@ func (s *ValidatorSigner) SignKeccak256(payload []byte) ([]byte, error) { hash := crypto.Keccak256Hash(payload) // Sign the hash using secp256k1 - signature, err := crypto.Sign(hash.Bytes(), s.privateKey) - if err != nil { - return nil, fmt.Errorf("failed to sign payload: %w", err) - } - - // crypto.Sign returns 65-byte signature [R || S || V] where V is 0 or 1 - // EVM's ecrecover expects V as 27 or 28, so convert: - // V=0 (even Y) → 27, V=1 (odd Y) → 28 - signature[64] += 27 - - return signature, nil + return s.SignDigest(hash.Bytes()) } // PublicKey returns the public key associated with this signer (for verification). diff --git a/extensions/tn_attestation/signer_test.go b/extensions/tn_attestation/signer_test.go index aa75a381c..ae1a6f99b 100644 --- a/extensions/tn_attestation/signer_test.go +++ b/extensions/tn_attestation/signer_test.go @@ -1,6 +1,7 @@ package tn_attestation import ( + "crypto/sha256" "fmt" "sync" "testing" @@ -30,6 +31,34 @@ func TestValidatorSigner(t *testing.T) { assert.Contains(t, err.Error(), "private key cannot be nil") }) + t.Run("SignDigest", func(t *testing.T) { + privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) + require.NoError(t, err) + + signer, err := NewValidatorSigner(privateKey) + require.NoError(t, err) + + digest := sha256.Sum256([]byte("attestation payload")) + + signature, err := signer.SignDigest(digest[:]) + require.NoError(t, err) + assert.NotNil(t, signature) + assert.Equal(t, 65, len(signature)) + }) + + t.Run("SignDigestInvalidLength", func(t *testing.T) { + privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) + require.NoError(t, err) + + signer, err := NewValidatorSigner(privateKey) + require.NoError(t, err) + + signature, err := signer.SignDigest([]byte{}) + assert.Error(t, err) + assert.Nil(t, signature) + assert.Contains(t, err.Error(), "digest must be 32 bytes") + }) + t.Run("SignKeccak256", func(t *testing.T) { // Generate a test private key privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) @@ -62,7 +91,7 @@ func TestValidatorSigner(t *testing.T) { assert.Contains(t, err.Error(), "payload cannot be empty") }) - t.Run("SignatureVerification", func(t *testing.T) { + t.Run("SignatureVerificationWithDigest", func(t *testing.T) { // Generate a test private key privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) require.NoError(t, err) @@ -72,9 +101,10 @@ func TestValidatorSigner(t *testing.T) { // Test payload payload := []byte("test attestation payload") + digest := sha256.Sum256(payload) - // Sign the payload - signature, err := signer.SignKeccak256(payload) + // Sign the digest + signature, err := signer.SignDigest(digest[:]) require.NoError(t, err) // Verify V byte is EVM-compatible (27 or 28) @@ -87,8 +117,7 @@ func TestValidatorSigner(t *testing.T) { copy(testSig, signature) testSig[64] -= 27 // Convert 27/28 → 0/1 - hash := crypto.Keccak256Hash(payload) - recoveredPubKey, err := crypto.SigToPub(hash.Bytes(), testSig) + recoveredPubKey, err := crypto.SigToPub(digest[:], testSig) require.NoError(t, err) // Verify the recovered public key matches the signer's public key @@ -125,7 +154,7 @@ func TestValidatorSigner(t *testing.T) { assert.True(t, address[:2] == "0x", "address should start with 0x") }) - t.Run("DeterministicSignature", func(t *testing.T) { + t.Run("DeterministicSignatureDigest", func(t *testing.T) { privateKey, _, err := kwilcrypto.GenerateSecp256k1Key(nil) require.NoError(t, err) @@ -133,12 +162,13 @@ func TestValidatorSigner(t *testing.T) { require.NoError(t, err) payload := []byte("deterministic test payload") + digest := sha256.Sum256(payload) // Sign the same payload twice - sig1, err := signer.SignKeccak256(payload) + sig1, err := signer.SignDigest(digest[:]) require.NoError(t, err) - sig2, err := signer.SignKeccak256(payload) + sig2, err := signer.SignDigest(digest[:]) require.NoError(t, err) // Signatures should be identical for the same payload and key @@ -161,7 +191,8 @@ func TestValidatorSigner(t *testing.T) { go func(idx int) { defer wg.Done() payload := []byte("concurrent test payload") - signature, err := signer.SignKeccak256(payload) + digest := sha256.Sum256(payload) + signature, err := signer.SignDigest(digest[:]) require.NoError(t, err) results[idx] = signature }(i) @@ -257,9 +288,10 @@ func TestEVMCompatibility(t *testing.T) { // Create test payload (simulating attestation structure) payload := []byte("version|algo|dataProvider|streamId|actionId|args|result") + digest := sha256.Sum256(payload) // Sign the payload - signature, err := signer.SignKeccak256(payload) + signature, err := signer.SignDigest(digest[:]) require.NoError(t, err) // Verify signature format is EVM-compatible @@ -270,16 +302,11 @@ func TestEVMCompatibility(t *testing.T) { assert.True(t, v == 27 || v == 28, "V must be 27 or 28 for EVM compatibility, got %d", v) // Recover the signer address from the signature (simulating Solidity ecrecover) - hash := crypto.Keccak256Hash(payload) - - // Note: Go's crypto.Ecrecover expects V as 0/1, but our signature has V as 27/28 (EVM format) - // In real usage, Solidity's ecrecover accepts 27/28 directly - // For testing with Go's crypto.Ecrecover, we need to convert back temporarily testSig := make([]byte, len(signature)) copy(testSig, signature) testSig[64] -= 27 // Convert 27/28 → 0/1 for Go's Ecrecover - recoveredPubKey, err := crypto.Ecrecover(hash.Bytes(), testSig) + recoveredPubKey, err := crypto.Ecrecover(digest[:], testSig) require.NoError(t, err) assert.NotNil(t, recoveredPubKey) @@ -303,7 +330,8 @@ func TestEVMCompatibility(t *testing.T) { // Test multiple signatures to ensure V is always 27 or 28 for i := 0; i < 10; i++ { payload := []byte(fmt.Sprintf("test payload %d", i)) - signature, err := signer.SignKeccak256(payload) + digest := sha256.Sum256(payload) + signature, err := signer.SignDigest(digest[:]) require.NoError(t, err) // Signature must be 65 bytes diff --git a/extensions/tn_attestation/tn_attestation.go b/extensions/tn_attestation/tn_attestation.go index af1331e85..c44bcb818 100644 --- a/extensions/tn_attestation/tn_attestation.go +++ b/extensions/tn_attestation/tn_attestation.go @@ -8,6 +8,7 @@ import ( appconf "github.com/trufnetwork/kwil-db/app/node/conf" "github.com/trufnetwork/kwil-db/common" "github.com/trufnetwork/kwil-db/config" + "github.com/trufnetwork/kwil-db/core/crypto/auth" "github.com/trufnetwork/kwil-db/extensions/hooks" "github.com/trufnetwork/node/extensions/leaderwatch" ) @@ -44,7 +45,12 @@ func engineReadyHook(ctx context.Context, app *common.App) error { return nil } - logger := app.Service.Logger.New(ExtensionName) + ext := getExtension() + ext.setService(app.Service) + ext.setApp(app) + ext.applyConfig(app.Service) + + logger := ext.Logger() // Load the validator's private key from the node key file rootDir := appconf.RootDir() @@ -70,6 +76,10 @@ func engineReadyHook(ctx context.Context, app *common.App) error { return fmt.Errorf("failed to initialize validator signer: %w", err) } + if ext.NodeSigner() == nil { + ext.setNodeSigner(auth.GetNodeSigner(privateKey)) + } + // Log the validator address for debugging signer := GetValidatorSigner() if signer != nil { @@ -78,6 +88,8 @@ func engineReadyHook(ctx context.Context, app *common.App) error { "validator_address", signer.Address()) } + ext.ensureBroadcaster(app.Service) + return nil } @@ -88,7 +100,13 @@ func onLeaderAcquire(ctx context.Context, app *common.App, block *common.BlockCo return } - logger := app.Service.Logger.New(ExtensionName) + ext := getExtension() + ext.setService(app.Service) + if block != nil { + ext.setLeader(true, block.Height) + } + + logger := ext.Logger() logger.Info("tn_attestation: acquired leadership") // TODO: Implement signing worker startup @@ -104,7 +122,13 @@ func onLeaderLose(ctx context.Context, app *common.App, block *common.BlockConte return } - logger := app.Service.Logger.New(ExtensionName) + ext := getExtension() + ext.setService(app.Service) + if block != nil { + ext.setLeader(false, block.Height) + } + + logger := ext.Logger() logger.Info("tn_attestation: lost leadership") // TODO: Implement signing worker shutdown @@ -114,37 +138,42 @@ func onLeaderLose(ctx context.Context, app *common.App, block *common.BlockConte } // onLeaderEndBlock is called on every EndBlock when the node is the leader. -// Currently dequeues and logs hashes to prevent unbounded memory growth. -// TODO: Implement actual signing and submission of attestations. +// It processes queued attestation hashes and, in later phases, also performs +// periodic scans to recover any missed notifications. func onLeaderEndBlock(ctx context.Context, app *common.App, block *common.BlockContext) { if app == nil || app.Service == nil { return } + ext := getExtension() + ext.setService(app.Service) + + if !ext.Leader() { + return + } + // Dequeue all pending attestation hashes to prevent unbounded growth queue := GetAttestationQueue() hashes := queue.DequeueAll() - // If there are hashes, log them (signing implementation pending in Issue 6) if len(hashes) > 0 { - logger := app.Service.Logger.New(ExtensionName) - logger.Info("tn_attestation: dequeued attestations for signing", + logger := ext.Logger() + logger.Info("tn_attestation: processing queued attestations", "count", len(hashes), - "block_height", block.Height, - "note", "signing implementation pending (Issue 6)") - - // TODO: Implement actual signing and submission - // Reference implementation: - // ext := GetExtension() - // for _, hash := range hashes { - // signature, err := ext.signAttestation(ctx, app, hash) - // if err != nil { - // logger.Error("failed to sign attestation", "hash", hash, "error", err) - // continue - // } - // if err := ext.submitSignature(ctx, app, hash, signature); err != nil { - // logger.Error("failed to submit signature", "hash", hash, "error", err) - // } - // } + "block_height", block.Height) + ext.processAttestationHashes(ctx, hashes) + } + + if block != nil && ext.shouldPerformScan(block.Height) { + logger := ext.Logger() + pending, err := ext.fetchPendingHashes(ctx, int(ext.ScanBatchLimit())) + if err != nil { + logger.Error("tn_attestation: fallback scan failed", "error", err) + } else if len(pending) > 0 { + logger.Info("tn_attestation: fallback scan found unsigned attestations", + "count", len(pending), + "block_height", block.Height) + ext.processAttestationHashes(ctx, pending) + } } } diff --git a/extensions/tn_attestation/tn_attestation_test.go b/extensions/tn_attestation/tn_attestation_test.go index 3379ff3ec..48db93779 100644 --- a/extensions/tn_attestation/tn_attestation_test.go +++ b/extensions/tn_attestation/tn_attestation_test.go @@ -3,15 +3,18 @@ package tn_attestation import ( "context" "fmt" + "strings" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/config" "github.com/trufnetwork/kwil-db/core/crypto" "github.com/trufnetwork/kwil-db/core/log" "github.com/trufnetwork/kwil-db/extensions/precompiles" + nodesql "github.com/trufnetwork/kwil-db/node/types/sql" ) // ensurePrecompileRegistered ensures the precompile is registered before use. @@ -453,3 +456,128 @@ func TestEngineReadyHook(t *testing.T) { // Note: Full integration test with actual key loading is deferred to Issue #1209 // where it will be tested as part of end-to-end leader signing workflow } + +func TestLeaderLifecycleState(t *testing.T) { + original := getExtension() + SetExtension(&signerExtension{ + logger: log.DiscardLogger, + scanIntervalBlocks: 100, + scanBatchLimit: 100, + }) + defer SetExtension(original) + + app := &common.App{ + Service: &common.Service{ + Logger: log.DiscardLogger, + }, + } + block := &common.BlockContext{Height: 10} + + onLeaderAcquire(context.Background(), app, block) + ext := getExtension() + assert.True(t, ext.Leader(), "extension should mark leadership on acquire") + assert.Equal(t, int64(10), ext.LastScanHeight(), "last scan height should seed from acquire") + + queue := GetAttestationQueue() + queue.Clear() + queue.Enqueue("hashA") + queue.Enqueue("hashB") + + onLeaderEndBlock(context.Background(), app, &common.BlockContext{Height: 11}) + assert.Equal(t, 0, queue.Len(), "leader end block should dequeue hashes") + + onLeaderLose(context.Background(), app, &common.BlockContext{Height: 12}) + assert.False(t, ext.Leader(), "extension should unset leadership on lose") + + queue.Enqueue("hashC") + onLeaderEndBlock(context.Background(), app, &common.BlockContext{Height: 13}) + assert.Equal(t, 1, queue.Len(), "non-leader end block should not tamper with queue") + queue.Clear() +} + +func TestOnLeaderEndBlockFallbackScan(t *testing.T) { + original := getExtension() + queue := GetAttestationQueue() + queue.Clear() + + ext := &signerExtension{ + logger: log.DiscardLogger, + scanIntervalBlocks: 1, + scanBatchLimit: 5, + } + SetExtension(ext) + t.Cleanup(func() { + SetExtension(original) + queue.Clear() + }) + + service := &common.Service{ + Logger: log.DiscardLogger, + LocalConfig: &config.Config{}, + } + + engine := &fallbackEngine{hashes: []string{"abc"}} + ext.setService(service) + ext.setApp(&common.App{ + Service: service, + Engine: engine, + DB: fallbackDB{}, + }) + ext.setLeader(true, 0) + + processed := make([][]string, 0) + ext.setProcessOverride(func(_ context.Context, hashes []string) { + processed = append(processed, append([]string(nil), hashes...)) + }) + + onLeaderEndBlock(context.Background(), &common.App{Service: service}, &common.BlockContext{Height: 1}) + + require.Len(t, processed, 1) + assert.Equal(t, []string{"abc"}, processed[0]) + assert.True(t, strings.Contains(engine.lastStatement, "GROUP BY attestation_hash")) +} + +type fallbackEngine struct { + hashes []string + lastStatement string +} + +func (f *fallbackEngine) Call(ctx *common.EngineContext, db nodesql.DB, namespace, action string, args []any, resultFn func(*common.Row) error) (*common.CallResult, error) { + panic("not implemented") +} + +func (f *fallbackEngine) CallWithoutEngineCtx(ctx context.Context, db nodesql.DB, namespace, action string, args []any, resultFn func(*common.Row) error) (*common.CallResult, error) { + panic("not implemented") +} + +func (f *fallbackEngine) Execute(ctx *common.EngineContext, db nodesql.DB, statement string, params map[string]any, fn func(*common.Row) error) error { + panic("not implemented") +} + +func (f *fallbackEngine) ExecuteWithoutEngineCtx(ctx context.Context, db nodesql.DB, statement string, params map[string]any, fn func(*common.Row) error) error { + f.lastStatement = statement + if strings.Contains(statement, "GROUP BY attestation_hash") { + rows := f.hashes + if limit, ok := params["limit"]; ok { + if n := toInt(limit); n >= 0 && n < len(rows) { + rows = rows[:n] + } + } + for _, h := range rows { + if err := fn(&common.Row{Values: []any{h}}); err != nil { + return err + } + } + } + return nil +} + +type fallbackDB struct{} + +func (fallbackDB) Execute(ctx context.Context, stmt string, args ...any) (*nodesql.ResultSet, error) { + return nil, fmt.Errorf("not implemented") +} + +func (fallbackDB) BeginTx(ctx context.Context) (nodesql.Tx, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/extensions/tn_attestation/worker.go b/extensions/tn_attestation/worker.go new file mode 100644 index 000000000..76c6d6c29 --- /dev/null +++ b/extensions/tn_attestation/worker.go @@ -0,0 +1,147 @@ +package tn_attestation + +import ( + "context" + "fmt" + "strings" + + ktypes "github.com/trufnetwork/kwil-db/core/types" +) + +// processAttestationHashes iterates through every dequeued hash, prepares the +// canonical payload(s) for signing, and submits signatures back through +// consensus. All failures are logged and do not abort the remainder of the +// batch so we can make steady progress even when an individual record is +// problematic. +func (e *signerExtension) processAttestationHashes(ctx context.Context, hashes []string) { + if len(hashes) == 0 { + return + } + + e.mu.RLock() + override := e.processOverride + e.mu.RUnlock() + if override != nil { + override(ctx, hashes) + return + } + + logger := e.Logger() + for _, hashHex := range hashes { + prepared, err := e.prepareSigningWork(ctx, hashHex) + if err != nil { + logger.Error("tn_attestation: failed to prepare signing payload", "hash", hashHex, "error", err) + continue + } + if len(prepared) == 0 { + logger.Debug("tn_attestation: no unsigned rows for hash", "hash", hashHex) + continue + } + for _, item := range prepared { + if err := e.submitSignature(ctx, item); err != nil { + logger.Error("tn_attestation: submit signature failed", "hash", hashHex, "requester", fmt.Sprintf("%x", item.Requester), "error", err) + continue + } + logger.Debug("tn_attestation: attestation signed", "hash", hashHex, "requester", fmt.Sprintf("%x", item.Requester)) + } + } +} + +func (e *signerExtension) submitSignature(ctx context.Context, item *PreparedSignature) error { + if item == nil { + return fmt.Errorf("prepared signature is nil") + } + if len(item.Requester) == 0 { + return fmt.Errorf("requester not available for attestation hash %s", item.HashHex) + } + + service := e.Service() + if service == nil || service.GenesisConfig == nil { + return fmt.Errorf("service or genesis config not available") + } + if service.GenesisConfig.ChainID == "" { + return fmt.Errorf("chain id not configured") + } + + broadcaster := e.Broadcaster() + if broadcaster == nil { + return fmt.Errorf("transaction broadcaster unavailable") + } + + signer := e.NodeSigner() + if signer == nil { + return fmt.Errorf("node signer not initialised") + } + + accountID, err := ktypes.GetSignerAccount(signer) + if err != nil { + return fmt.Errorf("derive account id: %w", err) + } + + accounts := e.Accounts() + if accounts == nil { + return fmt.Errorf("accounts subsystem unavailable") + } + + db := e.DB() + if db == nil { + return fmt.Errorf("database handle unavailable") + } + + account, err := accounts.GetAccount(ctx, db, accountID) + var nonce uint64 = 1 + if err != nil { + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "not found") && !strings.Contains(msg, "no rows") { + return fmt.Errorf("get account: %w", err) + } + } else { + nonce = uint64(account.Nonce + 1) + } + + hashArg, err := ktypes.EncodeValue(item.Hash) + if err != nil { + return fmt.Errorf("encode hash argument: %w", err) + } + requesterArg, err := ktypes.EncodeValue(item.Requester) + if err != nil { + return fmt.Errorf("encode requester argument: %w", err) + } + heightArg, err := ktypes.EncodeValue(item.CreatedHeight) + if err != nil { + return fmt.Errorf("encode created_height argument: %w", err) + } + signatureArg, err := ktypes.EncodeValue(item.Signature) + if err != nil { + return fmt.Errorf("encode signature argument: %w", err) + } + + payload := &ktypes.ActionExecution{ + Namespace: "main", + Action: "sign_attestation", + Arguments: [][]*ktypes.EncodedValue{{ + hashArg, requesterArg, heightArg, signatureArg, + }}, + } + + tx, err := ktypes.CreateNodeTransaction(payload, service.GenesisConfig.ChainID, nonce) + if err != nil { + return fmt.Errorf("create tx: %w", err) + } + if err := tx.Sign(signer); err != nil { + return fmt.Errorf("sign tx: %w", err) + } + + _, result, err := broadcaster.BroadcastTx(ctx, tx, 1) + if err != nil { + return fmt.Errorf("broadcast tx: %w", err) + } + if result == nil { + return fmt.Errorf("transaction result missing") + } + if result.Code != uint32(ktypes.CodeOk) { + return fmt.Errorf("sign_attestation failed with code %d: %s", result.Code, result.Log) + } + + return nil +} diff --git a/internal/migrations/024-attestation-actions.sql b/internal/migrations/024-attestation-actions.sql index cfb31ade4..8a62987c1 100644 --- a/internal/migrations/024-attestation-actions.sql +++ b/internal/migrations/024-attestation-actions.sql @@ -87,16 +87,74 @@ $max_fee INT8 $created_height, NULL, NULL, NULL ); - -- Queue for signing (no-op on non-leader validators; handled by precompile) - tn_attestation.queue_for_signing(encode($attestation_hash, 'hex')); - - RETURN $attestation_hash; +-- Queue for signing (no-op on non-leader validators; handled by precompile) +tn_attestation.queue_for_signing(encode($attestation_hash, 'hex')); + +RETURN $attestation_hash; }; -- ----------------------------------------------------------------------------- --- TODO: sign_attestation --- Placeholder to avoid merge conflicts with the signing workflow. --- CREATE OR REPLACE ACTION sign_attestation(...) { ... }; +-- Leader-only action for recording validator signatures on attestations. +CREATE OR REPLACE ACTION sign_attestation( + $attestation_hash BYTEA, + $requester BYTEA, + $created_height INT8, + $signature BYTEA +) PUBLIC { + -- Only the current leader may submit signatures on-chain. + IF @leader_sender IS NULL OR @signer IS NULL OR @leader_sender != @signer { + $leader_hex TEXT := 'unknown'; + $signer_hex TEXT := 'unknown'; + IF @leader_sender IS NOT NULL { + $leader_hex := encode(@leader_sender, 'hex')::TEXT; + } + IF @signer IS NOT NULL { + $signer_hex := encode(@signer, 'hex')::TEXT; + } + ERROR('Only the current block leader may sign attestations. leader=' || $leader_hex || ' signer=' || $signer_hex); + } + + IF $attestation_hash IS NULL OR length($attestation_hash) = 0 { + ERROR('Attestation hash is required'); + } + IF $requester IS NULL OR length($requester) = 0 { + ERROR('Requester is required'); + } + IF $created_height IS NULL { + ERROR('Created height is required'); + } + IF $signature IS NULL OR length($signature) = 0 { + ERROR('Signature is required'); + } + + -- Ensure attestation exists and has not been signed yet. + $found BOOL := FALSE; + FOR $row IN + SELECT signature + FROM attestations + WHERE attestation_hash = $attestation_hash + AND requester = $requester + AND created_height = $created_height + LIMIT 1 + { + $found := TRUE; + IF $row.signature IS NOT NULL { + ERROR('Attestation already signed for requester at height ' || $created_height::TEXT); + } + } + IF NOT $found { + ERROR('Attestation not found for requester at height ' || $created_height::TEXT); + } + + -- Record signature, validator identity, and the height at which it was signed. + UPDATE attestations + SET signature = $signature, + validator_pubkey = @signer, + signed_height = @height + WHERE attestation_hash = $attestation_hash + AND requester = $requester + AND created_height = $created_height; +}; -- TODO: get_signed_attestation / list_attestations -- CREATE OR REPLACE ACTION get_signed_attestation(...) { ... }; diff --git a/tests/streams/attestation/attestation_request_test.go b/tests/streams/attestation/attestation_request_test.go index 17568b6f5..d095eaee3 100644 --- a/tests/streams/attestation/attestation_request_test.go +++ b/tests/streams/attestation/attestation_request_test.go @@ -141,6 +141,7 @@ func runAttestationHappyPath(t *testing.T, ctx context.Context, platform *kwilTe } type attestationRow struct { + requester []byte attestationHash []byte resultCanonical []byte encryptSig bool @@ -156,24 +157,25 @@ func fetchAttestationRow(t *testing.T, ctx context.Context, platform *kwilTestin var rowData attestationRow err = platform.Engine.Execute(engineCtx, platform.DB, ` -SELECT attestation_hash, result_canonical, encrypt_sig, signature, validator_pubkey, signed_height, created_height +SELECT requester, attestation_hash, result_canonical, encrypt_sig, signature, validator_pubkey, signed_height, created_height FROM attestations WHERE attestation_hash = $hash; `, map[string]any{"hash": hash}, func(row *common.Row) error { - rowData.attestationHash = append([]byte(nil), row.Values[0].([]byte)...) - rowData.resultCanonical = append([]byte(nil), row.Values[1].([]byte)...) - rowData.encryptSig = row.Values[2].(bool) - if row.Values[3] != nil { - rowData.signature = append([]byte(nil), row.Values[3].([]byte)...) - } + rowData.requester = append([]byte(nil), row.Values[0].([]byte)...) + rowData.attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + rowData.resultCanonical = append([]byte(nil), row.Values[2].([]byte)...) + rowData.encryptSig = row.Values[3].(bool) if row.Values[4] != nil { - rowData.validatorPubKey = append([]byte(nil), row.Values[4].([]byte)...) + rowData.signature = append([]byte(nil), row.Values[4].([]byte)...) } if row.Values[5] != nil { - height := row.Values[5].(int64) + rowData.validatorPubKey = append([]byte(nil), row.Values[5].([]byte)...) + } + if row.Values[6] != nil { + height := row.Values[6].(int64) rowData.signedHeight = &height } - rowData.createdHeight = row.Values[6].(int64) + rowData.createdHeight = row.Values[7].(int64) return nil }) require.NoError(t, err) From 18e4505214164a2d059f29194466bacc1afe8ea0 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Fri, 10 Oct 2025 18:07:07 -0300 Subject: [PATCH 03/15] refactor: update tn_attestation integration tests and SQL action validations - Removed redundant length checks for attestation_hash, requester, and signature in the SQL action to simplify validation logic. - Enhanced comments in the integration test to clarify the workflow being tested, focusing on the complete production signing process. - Adjusted the test setup to ensure the tn_attestation precompile is registered correctly for testing. These changes improve the clarity and maintainability of the integration tests and SQL action validations, ensuring a more robust attestation signing workflow. --- .../harness_integration_test.go | 67 ++++++++----------- .../migrations/024-attestation-actions.sql | 6 +- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/extensions/tn_attestation/harness_integration_test.go b/extensions/tn_attestation/harness_integration_test.go index 264caf199..c4a0c7260 100644 --- a/extensions/tn_attestation/harness_integration_test.go +++ b/extensions/tn_attestation/harness_integration_test.go @@ -16,7 +16,6 @@ import ( "github.com/trufnetwork/kwil-db/core/crypto/auth" "github.com/trufnetwork/kwil-db/core/log" ktypes "github.com/trufnetwork/kwil-db/core/types" - extauth "github.com/trufnetwork/kwil-db/extensions/auth" "github.com/trufnetwork/kwil-db/extensions/precompiles" erc20shim "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" orderedsync "github.com/trufnetwork/kwil-db/node/exts/ordered-sync" @@ -29,7 +28,7 @@ import ( ) func init() { - // Register extension precompiles for tests + // Register extension precompiles for tests (except tn_attestation which tests handle individually) err := precompiles.RegisterInitializer(tn_cache.ExtensionName, tn_cache.InitializeCachePrecompile) if err != nil { panic("failed to register tn_cache precompiles: " + err.Error()) @@ -41,14 +40,14 @@ func init() { } tn_utils.InitializeExtension() - InitializeExtension() + // Note: tn_attestation precompile is registered by individual tests via ensurePrecompileRegistered() } func TestSigningWorkflowWithHarness(t *testing.T) { - // This is a high-level smoke test that exercises the SQL migration plus the Go - // extension working together. Rather than stubbing everything, we lean on the - // harness to spin up the database, run the real migration, and verify that an - // attestation request leaves a canonical row that our Go helpers understand. + // Integration test covering the complete production signing workflow: + // request_attestation (SQL) → prepareSigningWork (Go) → submitSignature (Go) + // → transaction marshaling → sign_attestation (SQL) with leader authorization. + // Tests that real migrations work correctly with transaction encoding/decoding. const ( testActionName = "harness_attestation_action" testActionID = 21 @@ -60,6 +59,10 @@ func TestSigningWorkflowWithHarness(t *testing.T) { erc20shim.ForTestingResetSingleton() erc20shim.ForTestingClearAllInstances(context.Background(), nil) + // Ensure tn_attestation precompile is registered (needed for queue_for_signing in migrations) + // Note: This is called here rather than in init() to allow other tests to test registration + ensurePrecompileRegistered(t) + ownerAddr := util.Unsafe_NewEthereumAddressFromString("0x0000000000000000000000000000000000000a22") requesterAddrValue := util.Unsafe_NewEthereumAddressFromString("0xabc0000000000000000000000000000000000a22") requesterAddr := &requesterAddrValue @@ -187,47 +190,35 @@ func TestSigningWorkflowWithHarness(t *testing.T) { require.Len(t, prepared[0].Signature, 65, "EVM signature is 65 bytes") require.Equal(t, stored.createdHeight, prepared[0].CreatedHeight) - // Phase 3: Execute sign_attestation action directly with real migrations + // Phase 3: Submit signature via production flow (tests transaction marshaling) const signHeight = int64(42) - // Create context as the block leader for sign_attestation - signerAddr := kcrypto.EthereumAddressFromPubKey(pubKey) - caller, err := extauth.GetIdentifier(auth.EthPersonalSignAuth, signerAddr) - require.NoError(t, err) - - signTxCtx := &common.TxContext{ - Ctx: ctx, - BlockContext: &common.BlockContext{ - Height: signHeight, - Proposer: pubKey, - }, - Signer: signerAddr, - Caller: caller, - TxID: platform.Txid(), - Authenticator: auth.EthPersonalSignAuth, + // Create test broadcaster that unmarshals transaction and executes sign_attestation + broadcaster := &harnessExecutingBroadcaster{ + t: t, + platform: platform, + pubKey: pubKey, + nodeSigner: nodeSigner, + signHeight: signHeight, } + ext.setBroadcaster(broadcaster) - // Call sign_attestation action from real migrations - signEngCtx := &common.EngineContext{TxContext: signTxCtx} - res, err := platform.Engine.Call(signEngCtx, platform.DB, "", "sign_attestation", []any{ - attestationHash, - requesterAddr.Bytes(), - stored.createdHeight, - prepared[0].Signature, - }, func(*common.Row) error { return nil }) - require.NoError(t, err) - require.NotNil(t, res) - if res.Error != nil { - t.Fatalf("sign_attestation failed: %v", res.Error) - } + // Use production submitSignature - this marshals the transaction, + // the broadcaster unmarshals it, and executes the real SQL action + err = ext.submitSignature(ctx, prepared[0]) + require.NoError(t, err, "submitSignature should succeed") + + // Verify the broadcaster was called and executed successfully + require.Equal(t, 1, broadcaster.calls, "should broadcast exactly once") // Verify signed state in database signedRow := fetchAttestationRowHarness(t, ctx, platform, attestationHash) require.NotNil(t, signedRow.signature, "signature should be recorded") require.Equal(t, prepared[0].Signature, signedRow.signature) require.NotNil(t, signedRow.validatorPubKey, "validator pubkey should be recorded") - // validator_pubkey is set to @signer which is the Ethereum address - require.Equal(t, signerAddr, signedRow.validatorPubKey) + // validator_pubkey is set to @signer which is the Ethereum address derived from proposer + expectedSignerAddr := kcrypto.EthereumAddressFromPubKey(pubKey) + require.Equal(t, expectedSignerAddr, signedRow.validatorPubKey) require.NotNil(t, signedRow.signedHeight, "signed height should be recorded") require.Equal(t, signHeight, *signedRow.signedHeight) diff --git a/internal/migrations/024-attestation-actions.sql b/internal/migrations/024-attestation-actions.sql index 8a62987c1..26c2a967a 100644 --- a/internal/migrations/024-attestation-actions.sql +++ b/internal/migrations/024-attestation-actions.sql @@ -114,16 +114,16 @@ CREATE OR REPLACE ACTION sign_attestation( ERROR('Only the current block leader may sign attestations. leader=' || $leader_hex || ' signer=' || $signer_hex); } - IF $attestation_hash IS NULL OR length($attestation_hash) = 0 { + IF $attestation_hash IS NULL { ERROR('Attestation hash is required'); } - IF $requester IS NULL OR length($requester) = 0 { + IF $requester IS NULL { ERROR('Requester is required'); } IF $created_height IS NULL { ERROR('Created height is required'); } - IF $signature IS NULL OR length($signature) = 0 { + IF $signature IS NULL { ERROR('Signature is required'); } From d77ecf9d48499bea28c677c355818e11a366e436 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Fri, 10 Oct 2025 18:16:29 -0300 Subject: [PATCH 04/15] docs: add documentation for tn_attestation package and enhance constants - Introduced a new doc.go file for the tn_attestation package, detailing the attestation signing workflow and key components. - Added a comment to the constants.go file to clarify the purpose of the ExtensionName constant. These changes improve the documentation and clarity of the tn_attestation extension, aiding developers in understanding its functionality and usage. --- extensions/tn_attestation/constants.go | 1 + extensions/tn_attestation/doc.go | 15 ++++++++++++++ .../harness_integration_test.go | 20 +++++++++++-------- 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 extensions/tn_attestation/doc.go diff --git a/extensions/tn_attestation/constants.go b/extensions/tn_attestation/constants.go index 183b71dc4..fec8a15f9 100644 --- a/extensions/tn_attestation/constants.go +++ b/extensions/tn_attestation/constants.go @@ -1,3 +1,4 @@ package tn_attestation +// ExtensionName is the identifier for the attestation signing extension. const ExtensionName = "tn_attestation" diff --git a/extensions/tn_attestation/doc.go b/extensions/tn_attestation/doc.go new file mode 100644 index 000000000..339b775de --- /dev/null +++ b/extensions/tn_attestation/doc.go @@ -0,0 +1,15 @@ +// Package tn_attestation implements the attestation signing workflow for TN validators. +// +// When a user requests an attestation via request_attestation (SQL), the extension: +// 1. Queues the hash via queue_for_signing precompile (non-deterministic, leader-only) +// 2. Processes queued hashes on leader's EndBlock +// 3. Signs canonical payloads using the validator's secp256k1 key +// 4. Broadcasts sign_attestation transactions back to consensus +// +// Key components: +// - ValidatorSigner: Thread-safe secp256k1 signing with EVM compatibility +// - CanonicalPayload: Structured representation of the 8-field attestation format +// - Leader callbacks: OnAcquire, OnLose, OnEndBlock lifecycle hooks +// +// Initialize the extension by calling InitializeExtension() during node startup. +package tn_attestation diff --git a/extensions/tn_attestation/harness_integration_test.go b/extensions/tn_attestation/harness_integration_test.go index c4a0c7260..75da23e29 100644 --- a/extensions/tn_attestation/harness_integration_test.go +++ b/extensions/tn_attestation/harness_integration_test.go @@ -59,9 +59,15 @@ func TestSigningWorkflowWithHarness(t *testing.T) { erc20shim.ForTestingResetSingleton() erc20shim.ForTestingClearAllInstances(context.Background(), nil) - // Ensure tn_attestation precompile is registered (needed for queue_for_signing in migrations) - // Note: This is called here rather than in init() to allow other tests to test registration + // Ensure tn_attestation precompile is registered (needed for queue_for_signing in migrations). + // Track whether we registered it so we can clean up afterwards and not interfere with + // other tests that expect to perform the registration themselves. + registered := precompiles.RegisteredPrecompiles() + _, alreadyRegistered := registered[ExtensionName] ensurePrecompileRegistered(t) + if !alreadyRegistered { + defer delete(precompiles.RegisteredPrecompiles(), ExtensionName) + } ownerAddr := util.Unsafe_NewEthereumAddressFromString("0x0000000000000000000000000000000000000a22") requesterAddrValue := util.Unsafe_NewEthereumAddressFromString("0xabc0000000000000000000000000000000000a22") @@ -216,9 +222,7 @@ func TestSigningWorkflowWithHarness(t *testing.T) { require.NotNil(t, signedRow.signature, "signature should be recorded") require.Equal(t, prepared[0].Signature, signedRow.signature) require.NotNil(t, signedRow.validatorPubKey, "validator pubkey should be recorded") - // validator_pubkey is set to @signer which is the Ethereum address derived from proposer - expectedSignerAddr := kcrypto.EthereumAddressFromPubKey(pubKey) - require.Equal(t, expectedSignerAddr, signedRow.validatorPubKey) + require.Equal(t, nodeSigner.CompactID(), signedRow.validatorPubKey, "validator pubkey should match node signer identity") require.NotNil(t, signedRow.signedHeight, "signed height should be recorded") require.Equal(t, signHeight, *signedRow.signedHeight) @@ -367,7 +371,7 @@ func (b *harnessExecutingBroadcaster) BroadcastTx(ctx context.Context, tx *ktype // Get caller identifier for leader check // For leader authorization to work, Signer must be the Ethereum address derived from the proposer's public key - signerAddr := kcrypto.EthereumAddressFromPubKey(b.pubKey) + signer := b.nodeSigner.CompactID() caller, err := auth.GetNodeIdentifier(b.pubKey) require.NoError(b.t, err) @@ -378,10 +382,10 @@ func (b *harnessExecutingBroadcaster) BroadcastTx(ctx context.Context, tx *ktype Height: b.signHeight, Proposer: b.pubKey, }, - Signer: signerAddr, + Signer: signer, Caller: caller, TxID: b.platform.Txid(), - Authenticator: auth.EthPersonalSignAuth, + Authenticator: auth.Secp256k1Auth, } // Execute real sign_attestation action from migrations From a5b560d03942143bdddb650e571e1e232f30815f Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Fri, 10 Oct 2025 18:47:22 -0300 Subject: [PATCH 05/15] refactor: enhance tn_attestation extension with improved broadcaster and transaction querying - Removed the txBroadcasterFunc and replaced it with a jsonRPCBroadcaster for better clarity and functionality. - Updated the ensureBroadcaster method to set both the broadcaster and transaction query client. - Introduced a new method to check transaction status asynchronously, improving logging and error handling. - Enhanced the submitSignature method to use non-blocking transaction broadcasting and added logging for transaction status. These changes improve the maintainability and functionality of the tn_attestation extension, ensuring efficient transaction handling and better integration with the signing workflow. --- extensions/tn_attestation/broadcast.go | 95 ++++++++++--------- extensions/tn_attestation/extension.go | 95 ++++++++++++++++++- extensions/tn_attestation/tn_attestation.go | 16 +++- .../tn_attestation/tn_attestation_test.go | 2 - extensions/tn_attestation/worker.go | 17 ++-- 5 files changed, 168 insertions(+), 57 deletions(-) diff --git a/extensions/tn_attestation/broadcast.go b/extensions/tn_attestation/broadcast.go index 58bc56ae1..26b0a9523 100644 --- a/extensions/tn_attestation/broadcast.go +++ b/extensions/tn_attestation/broadcast.go @@ -14,18 +14,11 @@ import ( "github.com/trufnetwork/kwil-db/core/types" ) -// txBroadcasterFunc adapts a plain function into a TxBroadcaster. -type txBroadcasterFunc func(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) - -func (f txBroadcasterFunc) BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) { - return f(ctx, tx, sync) -} - func (e *signerExtension) ensureBroadcaster(service *common.Service) { if service == nil || service.LocalConfig == nil { return } - if e.Broadcaster() != nil { + if e.Broadcaster() != nil && e.TxQueryClient() != nil { return } @@ -50,7 +43,9 @@ func (e *signerExtension) ensureBroadcaster(service *common.Service) { return } - e.setBroadcaster(makeBroadcasterFromURL(u)) + broadcaster, queryClient := makeBroadcasterFromURL(u) + e.setBroadcaster(broadcaster) + e.setTxQueryClient(queryClient) } func normalizeListenAddressForClient(listen string) (*url.URL, error) { @@ -77,46 +72,58 @@ func normalizeListenAddressForClient(listen string) (*url.URL, error) { return u, nil } -func makeBroadcasterFromURL(u *url.URL) TxBroadcaster { +func makeBroadcasterFromURL(u *url.URL) (TxBroadcaster, TxQueryClient) { client := userjsonrpc.NewClient(u) - return txBroadcasterFunc(func(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) { - mode := rpcclient.BroadcastWaitAccept - if sync == uint8(rpcclient.BroadcastWaitCommit) || sync == 1 { - mode = rpcclient.BroadcastWaitCommit - } - hash, err := client.Broadcast(ctx, tx, mode) - if err != nil { - return types.Hash{}, nil, err - } + br := &jsonRPCBroadcaster{client: client} + return br, br +} - var resp *types.TxQueryResponse - var queryErr error - if mode == rpcclient.BroadcastWaitAccept { - for tries := 0; tries < 10; tries++ { - resp, queryErr = client.TxQuery(ctx, hash) - if queryErr == nil && resp != nil && resp.Result != nil { - break - } - select { - case <-ctx.Done(): - return types.Hash{}, nil, ctx.Err() - case <-time.After(200 * time.Millisecond): - } - } - if queryErr != nil { - return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) +type jsonRPCBroadcaster struct { + client *userjsonrpc.Client +} + +func (b *jsonRPCBroadcaster) BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) { + mode := rpcclient.BroadcastWaitAccept + if sync == uint8(rpcclient.BroadcastWaitCommit) || sync == 1 { + mode = rpcclient.BroadcastWaitCommit + } + + hash, err := b.client.Broadcast(ctx, tx, mode) + if err != nil { + return types.Hash{}, nil, err + } + + var resp *types.TxQueryResponse + var queryErr error + if mode == rpcclient.BroadcastWaitAccept { + for tries := 0; tries < 10; tries++ { + resp, queryErr = b.client.TxQuery(ctx, hash) + if queryErr == nil && resp != nil && resp.Result != nil { + break } - } else { - resp, queryErr = client.TxQuery(ctx, hash) - if queryErr != nil { - return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) + select { + case <-ctx.Done(): + return types.Hash{}, nil, ctx.Err() + case <-time.After(200 * time.Millisecond): } } - - if resp == nil || resp.Result == nil { - return types.Hash{}, nil, fmt.Errorf("transaction result missing") + if queryErr != nil { + return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) + } + } else { + resp, queryErr = b.client.TxQuery(ctx, hash) + if queryErr != nil { + return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) } + } + + if resp == nil || resp.Result == nil { + return types.Hash{}, nil, fmt.Errorf("transaction result missing") + } + + return hash, resp.Result, nil +} - return hash, resp.Result, nil - }) +func (b *jsonRPCBroadcaster) TxQuery(ctx context.Context, txHash types.Hash) (*types.TxQueryResponse, error) { + return b.client.TxQuery(ctx, txHash) } diff --git a/extensions/tn_attestation/extension.go b/extensions/tn_attestation/extension.go index a9029f1c6..3c3ea6bc9 100644 --- a/extensions/tn_attestation/extension.go +++ b/extensions/tn_attestation/extension.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "sync" + "time" "github.com/trufnetwork/kwil-db/common" "github.com/trufnetwork/kwil-db/core/crypto/auth" "github.com/trufnetwork/kwil-db/core/log" "github.com/trufnetwork/kwil-db/core/types" + ktypes "github.com/trufnetwork/kwil-db/core/types" sql "github.com/trufnetwork/kwil-db/node/types/sql" ) @@ -28,8 +30,9 @@ type signerExtension struct { db sql.DB accounts common.Accounts - broadcaster TxBroadcaster - nodeSigner auth.Signer + broadcaster TxBroadcaster + txQueryClient TxQueryClient + nodeSigner auth.Signer processOverride func(context.Context, []string) @@ -148,6 +151,18 @@ func (e *signerExtension) Broadcaster() TxBroadcaster { return e.broadcaster } +func (e *signerExtension) setTxQueryClient(q TxQueryClient) { + e.mu.Lock() + defer e.mu.Unlock() + e.txQueryClient = q +} + +func (e *signerExtension) TxQueryClient() TxQueryClient { + e.mu.RLock() + defer e.mu.RUnlock() + return e.txQueryClient +} + func (e *signerExtension) setNodeSigner(s auth.Signer) { e.mu.Lock() defer e.mu.Unlock() @@ -260,8 +275,84 @@ func parsePositiveInt64(raw string) (int64, error) { return v, nil } +// checkTransactionStatus asynchronously checks transaction status and logs the result +func (e *signerExtension) checkTransactionStatus(ctx context.Context, txHash types.Hash, attestationHash string, requester []byte) { + // Try to get a query client from the broadcaster + client := e.getTxQueryClient() + if client == nil { + return + } + attempts := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second} + maxAttempts := 12 // roughly 2 minutes total wait + attempts = append(attempts, make([]time.Duration, maxAttempts-len(attempts))...) + for i := len(attempts) - (maxAttempts - len(attempts)); i < len(attempts); i++ { + attempts[i] = 10 * time.Second + } + logger := e.Logger() + + for i, delay := range attempts { + if i > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + } + + resp, err := client.TxQuery(ctx, txHash) + if err != nil { + if i == len(attempts)-1 { + logger.Warn("tn_attestation: transaction status unknown", + "hash", attestationHash, + "tx_hash", txHash, + "attempt", i+1, + "requester", fmt.Sprintf("%x", requester), + "error", err) + } + continue + } + + if resp.Height <= 0 { + continue + } + + if resp.Result != nil && resp.Result.Code == uint32(ktypes.CodeOk) { + logger.Info("tn_attestation: transaction confirmed", + "hash", attestationHash, + "tx_hash", txHash, + "height", resp.Height, + "requester", fmt.Sprintf("%x", requester)) + } else { + code := uint32(0) + logMsg := "transaction result missing" + if resp.Result != nil { + code = resp.Result.Code + logMsg = resp.Result.Log + } + logger.Error("tn_attestation: transaction failed", + "hash", attestationHash, + "tx_hash", txHash, + "height", resp.Height, + "code", code, + "log", logMsg, + "requester", fmt.Sprintf("%x", requester)) + } + return + } +} + +// getTxQueryClient creates a query client from the broadcaster +func (e *signerExtension) getTxQueryClient() TxQueryClient { + return e.TxQueryClient() +} + // TxBroadcaster matches the subset of the JSON-RPC client used by the signing // worker to inject transactions. type TxBroadcaster interface { BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) } + +// TxQueryClient interface for querying transaction status +type TxQueryClient interface { + TxQuery(ctx context.Context, txHash types.Hash) (*types.TxQueryResponse, error) +} diff --git a/extensions/tn_attestation/tn_attestation.go b/extensions/tn_attestation/tn_attestation.go index c44bcb818..04a761081 100644 --- a/extensions/tn_attestation/tn_attestation.go +++ b/extensions/tn_attestation/tn_attestation.go @@ -16,7 +16,7 @@ import ( // InitializeExtension registers the tn_attestation extension. // This includes: // - Registering the queue_for_signing() precompile -// - Registering leader watch callbacks for signing worker // TODO: WIP +// - Registering leader watch callbacks for the signing workflow func InitializeExtension() { // Register the precompile for queue_for_signing() method if err := registerPrecompile(); err != nil { @@ -28,7 +28,7 @@ func InitializeExtension() { panic(fmt.Sprintf("failed to register %s engine ready hook: %v", ExtensionName, err)) } - // Register leader watch callbacks (for Issue 6 - leader signing worker) + // Register leader watch callbacks for signing workflow lifecycle if err := leaderwatch.Register(ExtensionName, leaderwatch.Callbacks{ OnAcquire: onLeaderAcquire, OnLose: onLeaderLose, @@ -109,6 +109,12 @@ func onLeaderAcquire(ctx context.Context, app *common.App, block *common.BlockCo logger := ext.Logger() logger.Info("tn_attestation: acquired leadership") + queue := GetAttestationQueue() + if n := queue.Len(); n > 0 { + logger.Debug("tn_attestation: clearing residual attestation queue after leader acquisition", "dropped", n) + } + queue.Clear() + // TODO: Implement signing worker startup // Reference implementation: // ext := GetExtension() @@ -131,6 +137,12 @@ func onLeaderLose(ctx context.Context, app *common.App, block *common.BlockConte logger := ext.Logger() logger.Info("tn_attestation: lost leadership") + queue := GetAttestationQueue() + if n := queue.Len(); n > 0 { + logger.Debug("tn_attestation: clearing attestation queue on leader loss", "dropped", n) + } + queue.Clear() + // TODO: Implement signing worker shutdown // Reference implementation: // ext := GetExtension() diff --git a/extensions/tn_attestation/tn_attestation_test.go b/extensions/tn_attestation/tn_attestation_test.go index 48db93779..83fab249c 100644 --- a/extensions/tn_attestation/tn_attestation_test.go +++ b/extensions/tn_attestation/tn_attestation_test.go @@ -453,8 +453,6 @@ func TestEngineReadyHook(t *testing.T) { assert.Nil(t, signer, "signer should not be initialized without key file") }) - // Note: Full integration test with actual key loading is deferred to Issue #1209 - // where it will be tested as part of end-to-end leader signing workflow } func TestLeaderLifecycleState(t *testing.T) { diff --git a/extensions/tn_attestation/worker.go b/extensions/tn_attestation/worker.go index 76c6d6c29..6ece57954 100644 --- a/extensions/tn_attestation/worker.go +++ b/extensions/tn_attestation/worker.go @@ -132,16 +132,19 @@ func (e *signerExtension) submitSignature(ctx context.Context, item *PreparedSig return fmt.Errorf("sign tx: %w", err) } - _, result, err := broadcaster.BroadcastTx(ctx, tx, 1) + txHash, _, err := broadcaster.BroadcastTx(ctx, tx, 0) // Use BroadcastWaitAccept to avoid blocking consensus if err != nil { return fmt.Errorf("broadcast tx: %w", err) } - if result == nil { - return fmt.Errorf("transaction result missing") - } - if result.Code != uint32(ktypes.CodeOk) { - return fmt.Errorf("sign_attestation failed with code %d: %s", result.Code, result.Log) - } + + logger := e.Logger() + logger.Info("tn_attestation: signature broadcast", + "hash", item.HashHex, + "tx_hash", txHash, + "requester", fmt.Sprintf("%x", item.Requester)) + + // Async transaction status check for logging + go e.checkTransactionStatus(context.Background(), txHash, item.HashHex, item.Requester) return nil } From baf6494f57fa877fbc00d66045bb67232e652811 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Sat, 11 Oct 2025 19:36:44 -0300 Subject: [PATCH 06/15] refactor: enhance transaction status monitoring in tn_attestation extension - Introduced a dedicated background worker for asynchronous transaction status checks, improving responsiveness and logging. - Replaced blocking transaction status checks with a queuing mechanism to prevent interference with consensus processing. - Updated methods to streamline transaction broadcasting and status querying, ensuring better integration with the signing workflow. These changes enhance the efficiency and maintainability of the tn_attestation extension, providing improved operational visibility for transaction confirmations. --- extensions/tn_attestation/broadcast.go | 49 ++++----- extensions/tn_attestation/extension.go | 90 ++-------------- extensions/tn_attestation/worker.go | 138 ++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 106 deletions(-) diff --git a/extensions/tn_attestation/broadcast.go b/extensions/tn_attestation/broadcast.go index 26b0a9523..31486adc4 100644 --- a/extensions/tn_attestation/broadcast.go +++ b/extensions/tn_attestation/broadcast.go @@ -6,7 +6,6 @@ import ( "net" "net/url" "strings" - "time" "github.com/trufnetwork/kwil-db/common" rpcclient "github.com/trufnetwork/kwil-db/core/rpc/client" @@ -14,6 +13,9 @@ import ( "github.com/trufnetwork/kwil-db/core/types" ) +// ensureBroadcaster initializes the RPC client for transaction submission if not already set. +// Called during startup and leader acquisition to ensure the leader can broadcast sign_attestation +// transactions. Prefers extension-specific rpc_url config, falling back to node's RPC endpoint. func (e *signerExtension) ensureBroadcaster(service *common.Service) { if service == nil || service.LocalConfig == nil { return @@ -46,8 +48,12 @@ func (e *signerExtension) ensureBroadcaster(service *common.Service) { broadcaster, queryClient := makeBroadcasterFromURL(u) e.setBroadcaster(broadcaster) e.setTxQueryClient(queryClient) + e.startStatusWorker() } +// normalizeListenAddressForClient converts a server bind address (e.g., "0.0.0.0:8080") +// to a client-usable localhost URL. Needed because the extension runs on the same node +// but cannot connect to wildcard addresses like 0.0.0.0 or [::]. func normalizeListenAddressForClient(listen string) (*url.URL, error) { if listen == "" { return nil, fmt.Errorf("empty listen address") @@ -72,6 +78,8 @@ func normalizeListenAddressForClient(listen string) (*url.URL, error) { return u, nil } +// makeBroadcasterFromURL creates broadcaster and query client from the normalized RPC endpoint. +// Returns the same instance for both interfaces to share a single connection pool. func makeBroadcasterFromURL(u *url.URL) (TxBroadcaster, TxQueryClient) { client := userjsonrpc.NewClient(u) br := &jsonRPCBroadcaster{client: client} @@ -93,32 +101,16 @@ func (b *jsonRPCBroadcaster) BroadcastTx(ctx context.Context, tx *types.Transact return types.Hash{}, nil, err } - var resp *types.TxQueryResponse - var queryErr error if mode == rpcclient.BroadcastWaitAccept { - for tries := 0; tries < 10; tries++ { - resp, queryErr = b.client.TxQuery(ctx, hash) - if queryErr == nil && resp != nil && resp.Result != nil { - break - } - select { - case <-ctx.Done(): - return types.Hash{}, nil, ctx.Err() - case <-time.After(200 * time.Millisecond): - } - } - if queryErr != nil { - return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) - } - } else { - resp, queryErr = b.client.TxQuery(ctx, hash) - if queryErr != nil { - return types.Hash{}, nil, fmt.Errorf("tx query failed: %w", queryErr) - } + return hash, nil, nil } + resp, err := b.client.TxQuery(ctx, hash) + if err != nil { + return hash, nil, fmt.Errorf("tx query failed: %w", err) + } if resp == nil || resp.Result == nil { - return types.Hash{}, nil, fmt.Errorf("transaction result missing") + return hash, nil, fmt.Errorf("transaction result missing") } return hash, resp.Result, nil @@ -127,3 +119,14 @@ func (b *jsonRPCBroadcaster) BroadcastTx(ctx context.Context, tx *types.Transact func (b *jsonRPCBroadcaster) TxQuery(ctx context.Context, txHash types.Hash) (*types.TxQueryResponse, error) { return b.client.TxQuery(ctx, txHash) } + +// TxBroadcaster matches the subset of the JSON-RPC client used by the signing +// worker to inject transactions. +type TxBroadcaster interface { + BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) +} + +// TxQueryClient interface for querying transaction status. +type TxQueryClient interface { + TxQuery(ctx context.Context, txHash types.Hash) (*types.TxQueryResponse, error) +} diff --git a/extensions/tn_attestation/extension.go b/extensions/tn_attestation/extension.go index 3c3ea6bc9..1d7079c0f 100644 --- a/extensions/tn_attestation/extension.go +++ b/extensions/tn_attestation/extension.go @@ -4,13 +4,10 @@ import ( "context" "fmt" "sync" - "time" "github.com/trufnetwork/kwil-db/common" "github.com/trufnetwork/kwil-db/core/crypto/auth" "github.com/trufnetwork/kwil-db/core/log" - "github.com/trufnetwork/kwil-db/core/types" - ktypes "github.com/trufnetwork/kwil-db/core/types" sql "github.com/trufnetwork/kwil-db/node/types/sql" ) @@ -34,6 +31,9 @@ type signerExtension struct { txQueryClient TxQueryClient nodeSigner auth.Signer + statusOnce sync.Once + statusQueue chan txStatusWork + processOverride func(context.Context, []string) mu sync.RWMutex @@ -89,6 +89,8 @@ func (e *signerExtension) setService(svc *common.Service) { } } +// applyConfig reads extension-specific config values from service.LocalConfig.Extensions[ExtensionName]. +// Supports scan_interval_blocks and scan_batch_limit for tuning fallback DB scan behavior. func (e *signerExtension) applyConfig(service *common.Service) { if service == nil || service.LocalConfig == nil { return @@ -111,6 +113,8 @@ func (e *signerExtension) applyConfig(service *common.Service) { } } +// setApp captures references to engine, database, and accounts subsystems from the app. +// Called during hook registration to wire runtime dependencies. func (e *signerExtension) setApp(app *common.App) { e.mu.Lock() defer e.mu.Unlock() @@ -275,84 +279,8 @@ func parsePositiveInt64(raw string) (int64, error) { return v, nil } -// checkTransactionStatus asynchronously checks transaction status and logs the result -func (e *signerExtension) checkTransactionStatus(ctx context.Context, txHash types.Hash, attestationHash string, requester []byte) { - // Try to get a query client from the broadcaster - client := e.getTxQueryClient() - if client == nil { - return - } - attempts := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second} - maxAttempts := 12 // roughly 2 minutes total wait - attempts = append(attempts, make([]time.Duration, maxAttempts-len(attempts))...) - for i := len(attempts) - (maxAttempts - len(attempts)); i < len(attempts); i++ { - attempts[i] = 10 * time.Second - } - logger := e.Logger() - - for i, delay := range attempts { - if i > 0 { - select { - case <-ctx.Done(): - return - case <-time.After(delay): - } - } - - resp, err := client.TxQuery(ctx, txHash) - if err != nil { - if i == len(attempts)-1 { - logger.Warn("tn_attestation: transaction status unknown", - "hash", attestationHash, - "tx_hash", txHash, - "attempt", i+1, - "requester", fmt.Sprintf("%x", requester), - "error", err) - } - continue - } - - if resp.Height <= 0 { - continue - } - - if resp.Result != nil && resp.Result.Code == uint32(ktypes.CodeOk) { - logger.Info("tn_attestation: transaction confirmed", - "hash", attestationHash, - "tx_hash", txHash, - "height", resp.Height, - "requester", fmt.Sprintf("%x", requester)) - } else { - code := uint32(0) - logMsg := "transaction result missing" - if resp.Result != nil { - code = resp.Result.Code - logMsg = resp.Result.Log - } - logger.Error("tn_attestation: transaction failed", - "hash", attestationHash, - "tx_hash", txHash, - "height", resp.Height, - "code", code, - "log", logMsg, - "requester", fmt.Sprintf("%x", requester)) - } - return - } -} - -// getTxQueryClient creates a query client from the broadcaster +// getTxQueryClient retrieves the query client for transaction status polling. +// Returns nil if broadcaster not yet initialized. func (e *signerExtension) getTxQueryClient() TxQueryClient { return e.TxQueryClient() } - -// TxBroadcaster matches the subset of the JSON-RPC client used by the signing -// worker to inject transactions. -type TxBroadcaster interface { - BroadcastTx(ctx context.Context, tx *types.Transaction, sync uint8) (types.Hash, *types.TxResult, error) -} - -// TxQueryClient interface for querying transaction status -type TxQueryClient interface { - TxQuery(ctx context.Context, txHash types.Hash) (*types.TxQueryResponse, error) -} diff --git a/extensions/tn_attestation/worker.go b/extensions/tn_attestation/worker.go index 6ece57954..4de599aaa 100644 --- a/extensions/tn_attestation/worker.go +++ b/extensions/tn_attestation/worker.go @@ -1,13 +1,27 @@ +// This file implements the attestation signing worker and async transaction monitoring. +// +// Transaction status checking runs in a dedicated background goroutine to prevent blocking +// EndBlock processing. The worker polls for confirmation over ~2 minutes, logging outcomes +// for operational visibility without impacting consensus performance. package tn_attestation import ( "context" "fmt" "strings" + "time" ktypes "github.com/trufnetwork/kwil-db/core/types" ) +// txStatusWork queues a broadcast transaction for async status monitoring. +// Avoids blocking consensus by deferring potentially slow RPC queries to a worker goroutine. +type txStatusWork struct { + hash ktypes.Hash // Transaction hash from broadcast + attestationHash string // Original attestation hash for log correlation + requester []byte // Requester address for audit trail +} + // processAttestationHashes iterates through every dequeued hash, prepares the // canonical payload(s) for signing, and submits signatures back through // consensus. All failures are logged and do not abort the remainder of the @@ -143,8 +157,128 @@ func (e *signerExtension) submitSignature(ctx context.Context, item *PreparedSig "tx_hash", txHash, "requester", fmt.Sprintf("%x", item.Requester)) - // Async transaction status check for logging - go e.checkTransactionStatus(context.Background(), txHash, item.HashHex, item.Requester) + // Queue asynchronous transaction status check for logging + e.enqueueStatusCheck(txHash, item.HashHex, item.Requester) return nil } + +// startStatusWorker initializes the background goroutine that monitors transaction status. +// Uses sync.Once to prevent goroutine leaks across leader transitions. The 128-entry +// buffer provides headroom during burst signing without blocking EndBlock. +func (e *signerExtension) startStatusWorker() { + e.statusOnce.Do(func() { + if e.statusQueue == nil { + e.statusQueue = make(chan txStatusWork, 128) + } + go e.runStatusWorker() + }) +} + +// enqueueStatusCheck queues a transaction for async status monitoring. +// Drops entries if the queue is full to avoid blocking the signing workflow. +func (e *signerExtension) enqueueStatusCheck(txHash ktypes.Hash, attestationHash string, requester []byte) { + if e.getTxQueryClient() == nil { + return + } + if e.statusQueue == nil { + e.startStatusWorker() + } + work := txStatusWork{ + hash: txHash, + attestationHash: attestationHash, + requester: append([]byte(nil), requester...), + } + select { + case e.statusQueue <- work: + default: + e.Logger().Warn("tn_attestation: transaction status queue full, dropping entry", + "hash", attestationHash, + "tx_hash", txHash) + } +} + +// runStatusWorker consumes queued transactions and monitors each for confirmation. +// Runs for the lifetime of the extension process, surviving leader transitions. +func (e *signerExtension) runStatusWorker() { + for work := range e.statusQueue { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + e.monitorTransaction(ctx, work) + cancel() + } +} + +// monitorTransaction polls for transaction confirmation with exponential backoff. +// Queries up to 12 times over ~2 minutes (2s, 5s, then 10s intervals) to handle +// network delays and block production variance. Logs final outcome for observability. +func (e *signerExtension) monitorTransaction(ctx context.Context, work txStatusWork) { + client := e.getTxQueryClient() + if client == nil { + return + } + + delays := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second} + const maxAttempts = 12 + if len(delays) < maxAttempts { + extra := make([]time.Duration, maxAttempts-len(delays)) + for i := range extra { + extra[i] = 10 * time.Second + } + delays = append(delays, extra...) + } + + logger := e.Logger() + + for attempt, delay := range delays { + if attempt > 0 { + select { + case <-ctx.Done(): + logger.Warn("tn_attestation: transaction status check cancelled", + "hash", work.attestationHash, + "tx_hash", work.hash) + return + case <-time.After(delay): + } + } + + resp, err := client.TxQuery(ctx, work.hash) + if err != nil { + if attempt == len(delays)-1 { + logger.Warn("tn_attestation: transaction status unknown", + "hash", work.attestationHash, + "tx_hash", work.hash, + "attempt", attempt+1, + "requester", fmt.Sprintf("%x", work.requester), + "error", err) + } + continue + } + + if resp.Height <= 0 { + continue + } + + if resp.Result != nil && resp.Result.Code == uint32(ktypes.CodeOk) { + logger.Info("tn_attestation: transaction confirmed", + "hash", work.attestationHash, + "tx_hash", work.hash, + "height", resp.Height, + "requester", fmt.Sprintf("%x", work.requester)) + } else { + code := uint32(0) + logMsg := "transaction result missing" + if resp.Result != nil { + code = resp.Result.Code + logMsg = resp.Result.Log + } + logger.Error("tn_attestation: transaction failed", + "hash", work.attestationHash, + "tx_hash", work.hash, + "height", resp.Height, + "code", code, + "log", logMsg, + "requester", fmt.Sprintf("%x", work.requester)) + } + return + } +} From ee549061590368052c8dbae69096192fcf01b0c6 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Sat, 11 Oct 2025 19:57:58 -0300 Subject: [PATCH 07/15] test: add unit tests for transaction status worker in tn_attestation extension - Introduced a new test file for the status worker, implementing tests to verify successful transaction processing and retry logic on failure. - Created a fake transaction query client to simulate responses and errors, ensuring robust testing of the status worker's behavior. - Enhanced the testing framework to validate the asynchronous handling of transaction status checks. These changes improve the test coverage and reliability of the tn_attestation extension, ensuring proper functionality of the transaction status monitoring system. --- .../tn_attestation/status_worker_test.go | 138 ++++++++++++++++++ extensions/tn_attestation/worker.go | 17 ++- 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 extensions/tn_attestation/status_worker_test.go diff --git a/extensions/tn_attestation/status_worker_test.go b/extensions/tn_attestation/status_worker_test.go new file mode 100644 index 000000000..73191ab75 --- /dev/null +++ b/extensions/tn_attestation/status_worker_test.go @@ -0,0 +1,138 @@ +package tn_attestation + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/core/log" + ktypes "github.com/trufnetwork/kwil-db/core/types" +) + +type fakeTxQueryClient struct { + mu sync.Mutex + responses []*ktypes.TxQueryResponse + errs []error + expected int + calls int + done chan struct{} +} + +func newFakeTxQueryClient(resps []*ktypes.TxQueryResponse, errs []error, expected int) *fakeTxQueryClient { + if expected == 0 { + expected = len(resps) + if len(errs) > expected { + expected = len(errs) + } + if expected == 0 { + expected = 1 + } + } + return &fakeTxQueryClient{ + responses: resps, + errs: errs, + expected: expected, + done: make(chan struct{}), + } +} + +func (f *fakeTxQueryClient) TxQuery(ctx context.Context, txHash ktypes.Hash) (*ktypes.TxQueryResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + + var resp *ktypes.TxQueryResponse + if len(f.responses) > 0 { + resp = f.responses[0] + f.responses = f.responses[1:] + } + + var err error + if len(f.errs) > 0 { + err = f.errs[0] + f.errs = f.errs[1:] + } + + f.calls++ + if f.calls >= f.expected { + select { + case <-f.done: + default: + close(f.done) + } + } + + return resp, err +} + +func (f *fakeTxQueryClient) Calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +func TestStatusWorkerProcessesSuccess(t *testing.T) { + origDelays := statusRetryDelays + delays := make([]time.Duration, statusMaxAttempts) + for i := range delays { + delays[i] = time.Millisecond + } + statusRetryDelays = delays + defer func() { statusRetryDelays = origDelays }() + + ext := &signerExtension{ + logger: log.DiscardLogger, + } + + client := newFakeTxQueryClient([]*ktypes.TxQueryResponse{ + {Height: 10, Result: &ktypes.TxResult{Code: uint32(ktypes.CodeOk)}}, + }, nil, 1) + + ext.setTxQueryClient(client) + ext.startStatusWorker() + ext.enqueueStatusCheck(ktypes.Hash{}, "success-attestation", []byte("requester")) + + select { + case <-client.done: + case <-time.After(time.Second): + t.Fatal("transaction status worker did not complete in time") + } + + require.Equal(t, 1, client.Calls()) + close(ext.statusQueue) +} + +func TestStatusWorkerRetriesOnFailure(t *testing.T) { + origDelays := statusRetryDelays + delays := make([]time.Duration, statusMaxAttempts) + for i := range delays { + delays[i] = time.Millisecond + } + statusRetryDelays = delays + defer func() { statusRetryDelays = origDelays }() + + ext := &signerExtension{ + logger: log.DiscardLogger, + } + + errs := make([]error, statusMaxAttempts) + for i := range errs { + errs[i] = fmt.Errorf("tx not found") + } + client := newFakeTxQueryClient(nil, errs, statusMaxAttempts) + + ext.setTxQueryClient(client) + ext.startStatusWorker() + ext.enqueueStatusCheck(ktypes.Hash{}, "fail-attestation", []byte("requester")) + + select { + case <-client.done: + case <-time.After(2 * time.Second): + t.Fatal("transaction status worker did not exhaust retries in time") + } + + require.Equal(t, statusMaxAttempts, client.Calls()) + close(ext.statusQueue) +} diff --git a/extensions/tn_attestation/worker.go b/extensions/tn_attestation/worker.go index 4de599aaa..83484986c 100644 --- a/extensions/tn_attestation/worker.go +++ b/extensions/tn_attestation/worker.go @@ -14,6 +14,13 @@ import ( ktypes "github.com/trufnetwork/kwil-db/core/types" ) +const ( + statusMaxAttempts = 12 + statusWorkerTimeout = 2 * time.Minute +) + +var statusRetryDelays = []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second} + // txStatusWork queues a broadcast transaction for async status monitoring. // Avoids blocking consensus by deferring potentially slow RPC queries to a worker goroutine. type txStatusWork struct { @@ -202,7 +209,7 @@ func (e *signerExtension) enqueueStatusCheck(txHash ktypes.Hash, attestationHash // Runs for the lifetime of the extension process, surviving leader transitions. func (e *signerExtension) runStatusWorker() { for work := range e.statusQueue { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), statusWorkerTimeout) e.monitorTransaction(ctx, work) cancel() } @@ -217,10 +224,10 @@ func (e *signerExtension) monitorTransaction(ctx context.Context, work txStatusW return } - delays := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second} - const maxAttempts = 12 - if len(delays) < maxAttempts { - extra := make([]time.Duration, maxAttempts-len(delays)) + delays := make([]time.Duration, len(statusRetryDelays)) + copy(delays, statusRetryDelays) + if len(delays) < statusMaxAttempts { + extra := make([]time.Duration, statusMaxAttempts-len(delays)) for i := range extra { extra[i] = 10 * time.Second } From 097627822336b30e34f810facfe6b733ba49e8d1 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Sat, 11 Oct 2025 20:00:50 -0300 Subject: [PATCH 08/15] test: add documentation for newFakeTxQueryClient function in status_worker_test.go - Added comments to the newFakeTxQueryClient function to clarify its purpose and usage, including details on expected parameters and behavior. - This enhancement improves code readability and aids future developers in understanding the testing framework for the transaction status worker. These changes contribute to better documentation practices within the tn_attestation extension's test suite. --- extensions/tn_attestation/status_worker_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/tn_attestation/status_worker_test.go b/extensions/tn_attestation/status_worker_test.go index 73191ab75..d76037aa5 100644 --- a/extensions/tn_attestation/status_worker_test.go +++ b/extensions/tn_attestation/status_worker_test.go @@ -21,6 +21,9 @@ type fakeTxQueryClient struct { done chan struct{} } +// newFakeTxQueryClient builds a deterministic TxQuery client that returns the provided responses/errors. +// expected indicates how many calls we should observe before closing the done channel; 0 auto-expands +// to the longer of the responses/errors slices (at least 1). func newFakeTxQueryClient(resps []*ktypes.TxQueryResponse, errs []error, expected int) *fakeTxQueryClient { if expected == 0 { expected = len(resps) From 7118ae7ccfa3c04c02e6b1b8674f9815b8990f43 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Sat, 11 Oct 2025 20:04:54 -0300 Subject: [PATCH 09/15] docs: enhance comments in processor and worker for clarity - Added detailed comments in processor.go to explain the handling of unsigned attestations and the importance of hash verification during signing. - Improved comments in worker.go regarding nonce handling for signatures and the resilience of the status worker across leader transitions. These changes enhance code readability and provide better context for future developers working on the tn_attestation extension. --- extensions/tn_attestation/processor.go | 5 +++++ extensions/tn_attestation/worker.go | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/extensions/tn_attestation/processor.go b/extensions/tn_attestation/processor.go index b1f7d1ce6..414bad1b0 100644 --- a/extensions/tn_attestation/processor.go +++ b/extensions/tn_attestation/processor.go @@ -38,6 +38,8 @@ func (e *signerExtension) fetchUnsignedAttestations(ctx context.Context, hash [] return nil, fmt.Errorf("attestation extension not initialised with engine/db") } + // Returns multiple rows per hash: composite key is (hash, requester, created_height). + // Different requesters can request identical attestations. records := []attestationRecord{} err := engine.ExecuteWithoutEngineCtx( ctx, @@ -100,6 +102,9 @@ func (e *signerExtension) prepareSigningWork(ctx context.Context, hashHex string return nil, fmt.Errorf("parse canonical payload: %w", err) } + // Hash verification prevents signing corrupted/tampered payloads. SQL generates + // both canonical blob and hash independently; recomputing ensures they match. + // Without this, a corrupted result_canonical could produce wrong signatures. expectedHash := computeAttestationHash(payload) if !bytes.Equal(expectedHash[:], rec.hash) { return nil, fmt.Errorf("attestation hash mismatch: expected %x, got %x", rec.hash, expectedHash) diff --git a/extensions/tn_attestation/worker.go b/extensions/tn_attestation/worker.go index 83484986c..f818663a6 100644 --- a/extensions/tn_attestation/worker.go +++ b/extensions/tn_attestation/worker.go @@ -109,6 +109,9 @@ func (e *signerExtension) submitSignature(ctx context.Context, item *PreparedSig return fmt.Errorf("database handle unavailable") } + // Nonce handling: First signature ever gets nonce=1 (account doesn't exist yet). + // Subsequent signatures increment from last recorded nonce. We tolerate "not found" + // because leader's first-ever signature creates the account on-chain. account, err := accounts.GetAccount(ctx, db, accountID) var nonce uint64 = 1 if err != nil { @@ -173,6 +176,10 @@ func (e *signerExtension) submitSignature(ctx context.Context, item *PreparedSig // startStatusWorker initializes the background goroutine that monitors transaction status. // Uses sync.Once to prevent goroutine leaks across leader transitions. The 128-entry // buffer provides headroom during burst signing without blocking EndBlock. +// +// KEY INSIGHT: Worker survives leader changes intentionally. When node loses leadership, +// the queue still drains pending status checks for txs already broadcast. This ensures +// operators see outcomes for all signatures, not just those from the current term. func (e *signerExtension) startStatusWorker() { e.statusOnce.Do(func() { if e.statusQueue == nil { From a4e8c6cb74f58b9a17a8087c7c7609603c902670 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Mon, 13 Oct 2025 12:17:27 -0300 Subject: [PATCH 10/15] refactor: optimize canonical payload handling in tn_attestation extension - Updated the buildCanonical function to use Little Endian for length-prefixed data, improving consistency in payload generation. - Simplified the computeAttestationHash function to directly utilize the raw payload when available, enhancing performance. - Refactored the writeLengthPrefixed function for better clarity and reusability across the codebase. These changes enhance the efficiency and maintainability of the tn_attestation extension, ensuring more reliable payload processing and hashing. --- extensions/tn_attestation/canonical_test.go | 44 ++++++++++--------- extensions/tn_attestation/processor.go | 44 +++++++++++++------ extensions/tn_attestation/processor_test.go | 13 +----- extensions/tn_attestation/worker.go | 48 ++++++++++++--------- 4 files changed, 83 insertions(+), 66 deletions(-) diff --git a/extensions/tn_attestation/canonical_test.go b/extensions/tn_attestation/canonical_test.go index b774c8863..5d5c08f7a 100644 --- a/extensions/tn_attestation/canonical_test.go +++ b/extensions/tn_attestation/canonical_test.go @@ -61,30 +61,34 @@ func TestParseCanonicalPayload_ExtraBytes(t *testing.T) { // buildCanonical mirrors the SQL encoder to generate canonical payloads. func buildCanonical(version, algo uint8, height uint64, provider, stream []byte, actionID uint16, args, result []byte) []byte { - buf := bytes.NewBuffer(nil) - buf.WriteByte(version) - buf.WriteByte(algo) + buf := bytes.NewBuffer(nil) + buf.WriteByte(version) + buf.WriteByte(algo) - heightBytes := make([]byte, 8) - binary.BigEndian.PutUint64(heightBytes, height) - buf.Write(heightBytes) + heightBytes := make([]byte, 8) + binary.BigEndian.PutUint64(heightBytes, height) + buf.Write(heightBytes) - writeLengthPrefixed(buf, provider) - writeLengthPrefixed(buf, stream) + lengthBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(lengthBytes, uint32(len(provider))) + buf.Write(lengthBytes) + buf.Write(provider) - actionBytes := make([]byte, 2) - binary.BigEndian.PutUint16(actionBytes, actionID) - buf.Write(actionBytes) + binary.LittleEndian.PutUint32(lengthBytes, uint32(len(stream))) + buf.Write(lengthBytes) + buf.Write(stream) - writeLengthPrefixed(buf, args) - writeLengthPrefixed(buf, result) + actionBytes := make([]byte, 2) + binary.BigEndian.PutUint16(actionBytes, actionID) + buf.Write(actionBytes) - return buf.Bytes() -} + binary.LittleEndian.PutUint32(lengthBytes, uint32(len(args))) + buf.Write(lengthBytes) + buf.Write(args) + + binary.LittleEndian.PutUint32(lengthBytes, uint32(len(result))) + buf.Write(lengthBytes) + buf.Write(result) -func writeLengthPrefixed(buf *bytes.Buffer, chunk []byte) { - lengthBytes := make([]byte, 4) - binary.LittleEndian.PutUint32(lengthBytes, uint32(len(chunk))) - buf.Write(lengthBytes) - buf.Write(chunk) + return buf.Bytes() } diff --git a/extensions/tn_attestation/processor.go b/extensions/tn_attestation/processor.go index 414bad1b0..0a665043d 100644 --- a/extensions/tn_attestation/processor.go +++ b/extensions/tn_attestation/processor.go @@ -168,22 +168,40 @@ func (e *signerExtension) fetchPendingHashes(ctx context.Context, limit int) ([] if err != nil { return nil, err } - return hashes, nil +return hashes, nil } func computeAttestationHash(p *CanonicalPayload) [sha256.Size]byte { - var buf bytes.Buffer - buf.WriteByte(p.Version) - buf.WriteByte(p.Algorithm) - buf.Write(p.DataProvider) - buf.Write(p.StreamID) - - actionBytes := make([]byte, 2) - binary.BigEndian.PutUint16(actionBytes, p.ActionID) - buf.Write(actionBytes) - buf.Write(p.Args) - - return sha256.Sum256(buf.Bytes()) + if len(p.raw) > 0 { + return sha256.Sum256(p.raw) + } + + var buf bytes.Buffer + buf.WriteByte(p.Version) + buf.WriteByte(p.Algorithm) + + height := make([]byte, 8) + binary.BigEndian.PutUint64(height, p.BlockHeight) + buf.Write(height) + + writeLengthPrefixed(&buf, p.DataProvider) + writeLengthPrefixed(&buf, p.StreamID) + + actionBytes := make([]byte, 2) + binary.BigEndian.PutUint16(actionBytes, p.ActionID) + buf.Write(actionBytes) + + writeLengthPrefixed(&buf, p.Args) + writeLengthPrefixed(&buf, p.Result) + + return sha256.Sum256(buf.Bytes()) +} + +func writeLengthPrefixed(buf *bytes.Buffer, data []byte) { + length := make([]byte, 4) + binary.LittleEndian.PutUint32(length, uint32(len(data))) + buf.Write(length) + buf.Write(data) } func bytesClone(b []byte) []byte { diff --git a/extensions/tn_attestation/processor_test.go b/extensions/tn_attestation/processor_test.go index a9e8fbaa5..843a8f599 100644 --- a/extensions/tn_attestation/processor_test.go +++ b/extensions/tn_attestation/processor_test.go @@ -1,10 +1,8 @@ package tn_attestation import ( - "bytes" "context" "crypto/sha256" - "encoding/binary" "encoding/hex" "fmt" "math/big" @@ -31,16 +29,7 @@ func TestComputeAttestationHash(t *testing.T) { ActionID: 42, Args: []byte{0x01, 0x02}, } - var buf bytes.Buffer - buf.WriteByte(payload.Version) - buf.WriteByte(payload.Algorithm) - buf.Write(payload.DataProvider) - buf.Write(payload.StreamID) - actionBytes := make([]byte, 2) - binary.BigEndian.PutUint16(actionBytes, payload.ActionID) - buf.Write(actionBytes) - buf.Write(payload.Args) - expected := sha256.Sum256(buf.Bytes()) + expected := sha256.Sum256(payload.raw) actual := computeAttestationHash(payload) assert.Equal(t, expected, actual) diff --git a/extensions/tn_attestation/worker.go b/extensions/tn_attestation/worker.go index f818663a6..0f24b36c1 100644 --- a/extensions/tn_attestation/worker.go +++ b/extensions/tn_attestation/worker.go @@ -272,27 +272,33 @@ func (e *signerExtension) monitorTransaction(ctx context.Context, work txStatusW continue } - if resp.Result != nil && resp.Result.Code == uint32(ktypes.CodeOk) { - logger.Info("tn_attestation: transaction confirmed", - "hash", work.attestationHash, - "tx_hash", work.hash, - "height", resp.Height, - "requester", fmt.Sprintf("%x", work.requester)) - } else { - code := uint32(0) - logMsg := "transaction result missing" - if resp.Result != nil { - code = resp.Result.Code - logMsg = resp.Result.Log - } - logger.Error("tn_attestation: transaction failed", - "hash", work.attestationHash, - "tx_hash", work.hash, - "height", resp.Height, - "code", code, - "log", logMsg, - "requester", fmt.Sprintf("%x", work.requester)) + if resp.Result != nil && resp.Result.Code == uint32(ktypes.CodeOk) { + logger.Info("tn_attestation: transaction confirmed", + "hash", work.attestationHash, + "tx_hash", work.hash, + "height", resp.Height, + "requester", fmt.Sprintf("%x", work.requester)) + } else { + code := uint32(0) + logMsg := "transaction result missing" + if resp.Result != nil { + code = resp.Result.Code + logMsg = resp.Result.Log } - return + logger.Error("tn_attestation: transaction failed", + "hash", work.attestationHash, + "tx_hash", work.hash, + "height", resp.Height, + "code", code, + "log", logMsg, + "requester", fmt.Sprintf("%x", work.requester)) } + return +} + +logger.Warn("tn_attestation: transaction status unresolved after retries", + "hash", work.attestationHash, + "tx_hash", work.hash, + "requester", fmt.Sprintf("%x", work.requester), + "attempts", len(delays)) } From 961c465f434c369e96a07df51f91a5d47b62e8ad Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Mon, 13 Oct 2025 12:17:44 -0300 Subject: [PATCH 11/15] test: enhance ComputeAttestationHash tests in processor_test.go - Refactored the TestComputeAttestationHash function to improve clarity and structure. - Added tests to verify behavior when the raw payload is present and when it is missing, ensuring accurate hash computation. - Utilized a canonical payload builder for consistent test data generation. These changes improve test coverage and reliability for the attestation hash computation in the tn_attestation extension. --- extensions/tn_attestation/processor_test.go | 37 +++++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/extensions/tn_attestation/processor_test.go b/extensions/tn_attestation/processor_test.go index 843a8f599..6c5d23e93 100644 --- a/extensions/tn_attestation/processor_test.go +++ b/extensions/tn_attestation/processor_test.go @@ -21,18 +21,33 @@ import ( ) func TestComputeAttestationHash(t *testing.T) { - payload := &CanonicalPayload{ - Version: 1, - Algorithm: 1, - DataProvider: []byte("provider"), - StreamID: []byte("stream"), - ActionID: 42, - Args: []byte{0x01, 0x02}, - } - expected := sha256.Sum256(payload.raw) + const ( + version = uint8(1) + algorithm = uint8(1) + height = uint64(99) + actionID = uint16(7) + ) + dataProvider := []byte("provider") + streamID := []byte("stream") + args := []byte{0x01, 0x02} + result := []byte{0x03, 0x04} + + canonical := buildCanonical(version, algorithm, height, dataProvider, streamID, actionID, args, result) + payload, err := ParseCanonicalPayload(canonical) + require.NoError(t, err) + + t.Run("hashes canonical bytes when raw present", func(t *testing.T) { + expected := sha256.Sum256(canonical) + actual := computeAttestationHash(payload) + assert.Equal(t, expected, actual) + }) - actual := computeAttestationHash(payload) - assert.Equal(t, expected, actual) + t.Run("re-encodes when raw missing", func(t *testing.T) { + payload.raw = nil + expected := sha256.Sum256(canonical) + actual := computeAttestationHash(payload) + assert.Equal(t, expected, actual) + }) } func TestPrepareSigningWork(t *testing.T) { From c0a4f80e33679d585bba03bd87266f63e62a5a6e Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Mon, 13 Oct 2025 12:32:28 -0300 Subject: [PATCH 12/15] test: refactor TestSubmitSignature for improved clarity and reusability - Simplified the TestSubmitSignature function by introducing helper functions to decode byte and int64 arguments, enhancing readability and reducing code duplication. - Updated assertions to directly compare decoded values, ensuring clearer test expectations and error messages. - Improved error handling in the decoding process to provide more informative feedback on unexpected argument types. These changes enhance the maintainability and clarity of the tests in the tn_attestation extension, ensuring more reliable validation of signature submissions. --- extensions/tn_attestation/processor_test.go | 83 +++++++++------------ 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/extensions/tn_attestation/processor_test.go b/extensions/tn_attestation/processor_test.go index 6c5d23e93..6948ba7dc 100644 --- a/extensions/tn_attestation/processor_test.go +++ b/extensions/tn_attestation/processor_test.go @@ -182,61 +182,49 @@ func TestSubmitSignature(t *testing.T) { require.Len(t, decoded.Arguments, 1) require.Len(t, decoded.Arguments[0], 4) - hashVal, err := decoded.Arguments[0][0].Decode() - require.NoError(t, err) - var hashBytes []byte - switch typed := hashVal.(type) { - case []byte: - hashBytes = typed - case *[]byte: - require.NotNil(t, typed, "hash argument pointer was nil") - hashBytes = *typed - default: - t.Fatalf("unexpected hash argument type %T", hashVal) - } + hashBytes := decodeBytesArg(t, decoded.Arguments[0][0], "hash") assert.Equal(t, hash[:], hashBytes) - requesterVal, err := decoded.Arguments[0][1].Decode() + requesterBytes := decodeBytesArg(t, decoded.Arguments[0][1], "requester") + assert.Equal(t, []byte("requester"), requesterBytes) + + createdHeight := decodeInt64Arg(t, decoded.Arguments[0][2], "created_height") + assert.Equal(t, int64(123), createdHeight) + + signatureBytes := decodeBytesArg(t, decoded.Arguments[0][3], "signature") + assert.Len(t, signatureBytes, 65) +} + +func decodeBytesArg(t *testing.T, arg *ktypes.EncodedValue, fieldName string) []byte { + t.Helper() + val, err := arg.Decode() require.NoError(t, err) - var requesterBytes []byte - switch typed := requesterVal.(type) { + switch typed := val.(type) { case []byte: - requesterBytes = typed + return typed case *[]byte: - require.NotNil(t, typed, "requester argument pointer was nil") - requesterBytes = *typed + require.NotNil(t, typed, "%s argument pointer was nil", fieldName) + return *typed default: - t.Fatalf("unexpected requester argument type %T", requesterVal) + t.Fatalf("unexpected %s argument type %T", fieldName, val) + return nil } - assert.Equal(t, []byte("requester"), requesterBytes) +} - heightVal, err := decoded.Arguments[0][2].Decode() +func decodeInt64Arg(t *testing.T, arg *ktypes.EncodedValue, fieldName string) int64 { + t.Helper() + val, err := arg.Decode() require.NoError(t, err) - var createdHeight int64 - switch typed := heightVal.(type) { + switch typed := val.(type) { case int64: - createdHeight = typed + return typed case *int64: - require.NotNil(t, typed, "created_height argument pointer was nil") - createdHeight = *typed - default: - t.Fatalf("unexpected created_height argument type %T", heightVal) - } - assert.Equal(t, int64(123), createdHeight) - - sigVal, err := decoded.Arguments[0][3].Decode() - require.NoError(t, err) - var signatureBytes []byte - switch typed := sigVal.(type) { - case []byte: - signatureBytes = typed - case *[]byte: - require.NotNil(t, typed, "signature argument pointer was nil") - signatureBytes = *typed + require.NotNil(t, typed, "%s argument pointer was nil", fieldName) + return *typed default: - t.Fatalf("unexpected signature argument type %T", sigVal) + t.Fatalf("unexpected %s argument type %T", fieldName, val) + return 0 } - assert.Len(t, signatureBytes, 65) } func TestFetchPendingHashes(t *testing.T) { @@ -286,16 +274,17 @@ func (s *stubEngine) Execute(ctx *common.EngineContext, db nodesql.DB, statement } func (s *stubEngine) ExecuteWithoutEngineCtx(ctx context.Context, db nodesql.DB, statement string, params map[string]any, fn func(*common.Row) error) error { - var rows []*common.Row - if strings.Contains(statement, "GROUP BY attestation_hash") { - rows = s.hashRows + rows := s.rows + if params != nil { if limit, ok := params["limit"]; ok { + rows = s.hashRows if n := toInt(limit); n >= 0 && n < len(rows) { rows = rows[:n] } } - } else { - rows = s.rows + } else if strings.Contains(statement, "GROUP BY attestation_hash") { + // Fallback for callers that forget to pass params. + rows = s.hashRows } for _, row := range rows { if err := fn(row); err != nil { From 0ce7e986565f9e20bd96fd2af021001568ea8c0b Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Mon, 13 Oct 2025 12:54:03 -0300 Subject: [PATCH 13/15] refactor: improve computeAttestationHash function and related tests - Refactored the computeAttestationHash function to utilize a hasher for better performance and clarity. - Introduced a new helper function, buildHashMaterial, to streamline the construction of hash input data. - Updated tests in processor_test.go to reflect changes in hash computation logic, ensuring accurate validation of expected outputs. These changes enhance the efficiency and maintainability of the tn_attestation extension, improving the reliability of attestation hash computations. --- extensions/tn_attestation/processor.go | 46 +++++++-------------- extensions/tn_attestation/processor_test.go | 23 +++++++++-- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/extensions/tn_attestation/processor.go b/extensions/tn_attestation/processor.go index 0a665043d..f4aa4c849 100644 --- a/extensions/tn_attestation/processor.go +++ b/extensions/tn_attestation/processor.go @@ -168,40 +168,24 @@ func (e *signerExtension) fetchPendingHashes(ctx context.Context, limit int) ([] if err != nil { return nil, err } -return hashes, nil + return hashes, nil } func computeAttestationHash(p *CanonicalPayload) [sha256.Size]byte { - if len(p.raw) > 0 { - return sha256.Sum256(p.raw) - } - - var buf bytes.Buffer - buf.WriteByte(p.Version) - buf.WriteByte(p.Algorithm) - - height := make([]byte, 8) - binary.BigEndian.PutUint64(height, p.BlockHeight) - buf.Write(height) - - writeLengthPrefixed(&buf, p.DataProvider) - writeLengthPrefixed(&buf, p.StreamID) - - actionBytes := make([]byte, 2) - binary.BigEndian.PutUint16(actionBytes, p.ActionID) - buf.Write(actionBytes) - - writeLengthPrefixed(&buf, p.Args) - writeLengthPrefixed(&buf, p.Result) - - return sha256.Sum256(buf.Bytes()) -} - -func writeLengthPrefixed(buf *bytes.Buffer, data []byte) { - length := make([]byte, 4) - binary.LittleEndian.PutUint32(length, uint32(len(data))) - buf.Write(length) - buf.Write(data) + hasher := sha256.New() + hasher.Write([]byte{p.Version}) + hasher.Write([]byte{p.Algorithm}) + hasher.Write(p.DataProvider) + hasher.Write(p.StreamID) + + var actionBytes [2]byte + binary.BigEndian.PutUint16(actionBytes[:], p.ActionID) + hasher.Write(actionBytes[:]) + hasher.Write(p.Args) + + var digest [sha256.Size]byte + copy(digest[:], hasher.Sum(nil)) + return digest } func bytesClone(b []byte) []byte { diff --git a/extensions/tn_attestation/processor_test.go b/extensions/tn_attestation/processor_test.go index 6948ba7dc..ad5800a5e 100644 --- a/extensions/tn_attestation/processor_test.go +++ b/extensions/tn_attestation/processor_test.go @@ -1,8 +1,10 @@ package tn_attestation import ( + "bytes" "context" "crypto/sha256" + "encoding/binary" "encoding/hex" "fmt" "math/big" @@ -36,15 +38,15 @@ func TestComputeAttestationHash(t *testing.T) { payload, err := ParseCanonicalPayload(canonical) require.NoError(t, err) - t.Run("hashes canonical bytes when raw present", func(t *testing.T) { - expected := sha256.Sum256(canonical) + t.Run("hashes SQL fields when raw present", func(t *testing.T) { + expected := sha256.Sum256(buildHashMaterial(version, algorithm, dataProvider, streamID, actionID, args)) actual := computeAttestationHash(payload) assert.Equal(t, expected, actual) }) t.Run("re-encodes when raw missing", func(t *testing.T) { payload.raw = nil - expected := sha256.Sum256(canonical) + expected := sha256.Sum256(buildHashMaterial(version, algorithm, dataProvider, streamID, actionID, args)) actual := computeAttestationHash(payload) assert.Equal(t, expected, actual) }) @@ -227,6 +229,21 @@ func decodeInt64Arg(t *testing.T, arg *ktypes.EncodedValue, fieldName string) in } } +func buildHashMaterial(version, algo uint8, dataProvider, streamID []byte, actionID uint16, args []byte) []byte { + buf := bytes.NewBuffer(nil) + buf.WriteByte(version) + buf.WriteByte(algo) + buf.Write(dataProvider) + buf.Write(streamID) + + var actionBytes [2]byte + binary.BigEndian.PutUint16(actionBytes[:], actionID) + buf.Write(actionBytes[:]) + buf.Write(args) + + return buf.Bytes() +} + func TestFetchPendingHashes(t *testing.T) { ext := &signerExtension{ logger: log.DiscardLogger, From 4497ef2348d56ac27b93bddb5d2f3f07999f78b2 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Mon, 13 Oct 2025 13:19:32 -0300 Subject: [PATCH 14/15] test: streamline TestComputeAttestationHash for improved clarity - Refactored the TestComputeAttestationHash function to eliminate unnecessary sub-tests, enhancing readability and reducing complexity. - Consolidated assertions for hash computation when the raw payload is present and when it is missing, ensuring consistent validation of expected outputs. - Updated error messages in the prepareSigningWork function to provide clearer context on attestation hash mismatches. These changes improve the maintainability and clarity of the tests in the tn_attestation extension, ensuring more reliable validation of attestation hash computations. --- extensions/tn_attestation/processor.go | 27 +++++++++---------- extensions/tn_attestation/processor_test.go | 17 +++++------- .../migrations/024-attestation-actions.sql | 25 +++++++++-------- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/extensions/tn_attestation/processor.go b/extensions/tn_attestation/processor.go index f4aa4c849..60a954ea5 100644 --- a/extensions/tn_attestation/processor.go +++ b/extensions/tn_attestation/processor.go @@ -81,6 +81,9 @@ func (e *signerExtension) prepareSigningWork(ctx context.Context, hashHex string if err != nil { return nil, fmt.Errorf("invalid attestation hash %q: %w", hashHex, err) } + if len(hashBytes) != sha256.Size { + return nil, fmt.Errorf("attestation hash must be %d bytes, got %d", sha256.Size, len(hashBytes)) + } records, err := e.fetchUnsignedAttestations(ctx, hashBytes) if err != nil { @@ -102,12 +105,10 @@ func (e *signerExtension) prepareSigningWork(ctx context.Context, hashHex string return nil, fmt.Errorf("parse canonical payload: %w", err) } - // Hash verification prevents signing corrupted/tampered payloads. SQL generates - // both canonical blob and hash independently; recomputing ensures they match. - // Without this, a corrupted result_canonical could produce wrong signatures. + // Validate stored hash matches caller inputs; SQL computes it from request parameters. expectedHash := computeAttestationHash(payload) if !bytes.Equal(expectedHash[:], rec.hash) { - return nil, fmt.Errorf("attestation hash mismatch: expected %x, got %x", rec.hash, expectedHash) + return nil, fmt.Errorf("attestation hash mismatch: expected %x, db %x", expectedHash, rec.hash) } digest := payload.SigningDigest() @@ -172,20 +173,18 @@ func (e *signerExtension) fetchPendingHashes(ctx context.Context, limit int) ([] } func computeAttestationHash(p *CanonicalPayload) [sha256.Size]byte { - hasher := sha256.New() - hasher.Write([]byte{p.Version}) - hasher.Write([]byte{p.Algorithm}) - hasher.Write(p.DataProvider) - hasher.Write(p.StreamID) + buf := bytes.NewBuffer(nil) + buf.WriteByte(p.Version) + buf.WriteByte(p.Algorithm) + buf.Write(p.DataProvider) + buf.Write(p.StreamID) var actionBytes [2]byte binary.BigEndian.PutUint16(actionBytes[:], p.ActionID) - hasher.Write(actionBytes[:]) - hasher.Write(p.Args) + buf.Write(actionBytes[:]) + buf.Write(p.Args) - var digest [sha256.Size]byte - copy(digest[:], hasher.Sum(nil)) - return digest + return sha256.Sum256(buf.Bytes()) } func bytesClone(b []byte) []byte { diff --git a/extensions/tn_attestation/processor_test.go b/extensions/tn_attestation/processor_test.go index ad5800a5e..f13f2ca95 100644 --- a/extensions/tn_attestation/processor_test.go +++ b/extensions/tn_attestation/processor_test.go @@ -38,18 +38,13 @@ func TestComputeAttestationHash(t *testing.T) { payload, err := ParseCanonicalPayload(canonical) require.NoError(t, err) - t.Run("hashes SQL fields when raw present", func(t *testing.T) { - expected := sha256.Sum256(buildHashMaterial(version, algorithm, dataProvider, streamID, actionID, args)) - actual := computeAttestationHash(payload) - assert.Equal(t, expected, actual) - }) + expected := sha256.Sum256(buildHashMaterial(version, algorithm, dataProvider, streamID, actionID, args)) + actual := computeAttestationHash(payload) + assert.Equal(t, expected, actual) - t.Run("re-encodes when raw missing", func(t *testing.T) { - payload.raw = nil - expected := sha256.Sum256(buildHashMaterial(version, algorithm, dataProvider, streamID, actionID, args)) - actual := computeAttestationHash(payload) - assert.Equal(t, expected, actual) - }) + payload.raw = nil + actual = computeAttestationHash(payload) + assert.Equal(t, expected, actual) } func TestPrepareSigningWork(t *testing.T) { diff --git a/internal/migrations/024-attestation-actions.sql b/internal/migrations/024-attestation-actions.sql index 26c2a967a..da0c7d25e 100644 --- a/internal/migrations/024-attestation-actions.sql +++ b/internal/migrations/024-attestation-actions.sql @@ -44,7 +44,6 @@ $max_fee INT8 -- some args per action $query_result := tn_utils.call_dispatch($action_name, $args_bytes); - -- Calculate attestation hash from (version|algo|data_provider|stream_id|action_id|args) $version := 1; $algo := 1; -- secp256k1 -- Serialize canonical payload (version through result) using tn_utils helpers @@ -53,18 +52,6 @@ $max_fee INT8 $height_bytes := tn_utils.encode_uint64($created_height::INT); $action_id_bytes := tn_utils.encode_uint16($action_id::INT); - -- Build hash material in canonical order (no length prefixes) to match - -- the engine-side hashing utilities used by the signing service. - $hash_input := tn_utils.bytea_join(ARRAY[ - $version_bytes, - $algo_bytes, - $data_provider, - $stream_id, - $action_id_bytes, - $args_bytes - ], NULL); - $attestation_hash := digest($hash_input, 'sha256'); - -- Canonical payload mirrors Go helpers: each field length-prefixed so the -- validator can recover every component without ambiguity. $result_canonical := tn_utils.bytea_join(ARRAY[ @@ -78,6 +65,18 @@ $max_fee INT8 tn_utils.bytea_length_prefix($query_result) ], NULL); + -- Build hash material in canonical order using caller-provided inputs only. + -- This keeps the hash deterministic for clients (excludes block height and result). + $hash_input := tn_utils.bytea_join(ARRAY[ + $version_bytes, + $algo_bytes, + $data_provider, + $stream_id, + $action_id_bytes, + $args_bytes + ], NULL); + $attestation_hash := digest($hash_input, 'sha256'); + -- Store unsigned attestation INSERT INTO attestations ( attestation_hash, requester, result_canonical, encrypt_sig, From 9dcf571f31e541faa7526aeb8efd9d3bf6ef8c20 Mon Sep 17 00:00:00 2001 From: Raffael Campos Date: Mon, 13 Oct 2025 13:37:47 -0300 Subject: [PATCH 15/15] fix: update sign_attestation action to ensure signature is not null - Modified the sign_attestation action to include a condition that checks for a null signature, enhancing data integrity during attestation processing. - This change ensures that only valid attestations with a signature are processed, improving the reliability of the attestation workflow in the tn_attestation extension. --- internal/migrations/024-attestation-actions.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/migrations/024-attestation-actions.sql b/internal/migrations/024-attestation-actions.sql index da0c7d25e..8045d6160 100644 --- a/internal/migrations/024-attestation-actions.sql +++ b/internal/migrations/024-attestation-actions.sql @@ -152,7 +152,8 @@ CREATE OR REPLACE ACTION sign_attestation( signed_height = @height WHERE attestation_hash = $attestation_hash AND requester = $requester - AND created_height = $created_height; + AND created_height = $created_height + AND signature IS NULL; }; -- TODO: get_signed_attestation / list_attestations