diff --git a/internal/data/accounts.go b/internal/data/accounts.go index 9522e478..d2450c24 100644 --- a/internal/data/accounts.go +++ b/internal/data/accounts.go @@ -109,6 +109,7 @@ func (m *AccountModel) Delete(ctx context.Context, address string) error { return nil } +// BatchGetByIDs returns the subset of provided account IDs that exist in the accounts table. // BatchGetByIDs returns the subset of provided account IDs that exist in the accounts table. func (m *AccountModel) BatchGetByIDs(ctx context.Context, dbTx pgx.Tx, accountIDs []string) ([]string, error) { if len(accountIDs) == 0 { diff --git a/internal/data/accounts_test.go b/internal/data/accounts_test.go index 0dac677e..2e1abd43 100644 --- a/internal/data/accounts_test.go +++ b/internal/data/accounts_test.go @@ -266,8 +266,10 @@ func TestAccountModelBatchGetByToIDs(t *testing.T) { types.AddressBytea(address1), types.AddressBytea(address2)) require.NoError(t, err) - // Insert test transactions first - _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES ('tx1', $1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, NOW()), ('tx2', $2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, NOW())", toID1, toID2) + // Insert test transactions first (hash is BYTEA, using valid 64-char hex strings) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, $2, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, NOW()), ($3, $4, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, NOW())", testHash1, toID1, testHash2, toID2) require.NoError(t, err) // Insert test transactions_accounts links @@ -318,12 +320,16 @@ func TestAccountModelBatchGetByOperationIDs(t *testing.T) { types.AddressBytea(address1), types.AddressBytea(address2)) require.NoError(t, err) - // Insert test transactions first - _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, NOW()), ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, NOW())") + // Insert test transactions first (hash is BYTEA, using valid 64-char hex strings) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, NOW()), ($2, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, NOW())", testHash1, testHash2) require.NoError(t, err) // Insert test operations (IDs don't need to be in TOID range here since we're just testing operations_accounts links) - _, err = m.DB.ExecContext(ctx, "INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES ($1, 'PAYMENT', 'xdr1', 'op_success', true, 1, NOW()), ($2, 'PAYMENT', 'xdr2', 'op_success', true, 2, NOW())", operationID1, operationID2) + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + _, err = m.DB.ExecContext(ctx, "INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES ($1, 'PAYMENT', $3, 'op_success', true, 1, NOW()), ($2, 'PAYMENT', $4, 'op_success', true, 2, NOW())", operationID1, operationID2, xdr1, xdr2) require.NoError(t, err) // Insert test operations_accounts links (account_id is BYTEA) @@ -411,12 +417,16 @@ func TestAccountModelBatchGetByStateChangeIDs(t *testing.T) { types.AddressBytea(address1), types.AddressBytea(address2)) require.NoError(t, err) - // Insert test transactions first - _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, NOW()), ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, NOW())") + // Insert test transactions first (hash is BYTEA, using valid 64-char hex strings) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + _, err = m.DB.ExecContext(ctx, "INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES ($1, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, NOW()), ($2, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, NOW())", testHash1, testHash2) require.NoError(t, err) // Insert test operations (IDs must be in TOID range for each transaction) - _, err = m.DB.ExecContext(ctx, "INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, NOW()), (8193, 'PAYMENT', 'xdr2', 'op_success', true, 2, NOW())") + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + _, err = m.DB.ExecContext(ctx, "INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES (4097, 'PAYMENT', $1, 'op_success', true, 1, NOW()), (8193, 'PAYMENT', $2, 'op_success', true, 2, NOW())", xdr1, xdr2) require.NoError(t, err) // Insert test state changes that reference the accounts (state_changes.account_id is TEXT) diff --git a/internal/data/operations.go b/internal/data/operations.go index f97279ce..3253f2db 100644 --- a/internal/data/operations.go +++ b/internal/data/operations.go @@ -284,7 +284,7 @@ func (m *OperationModel) BatchInsert( // 1. Flatten the operations into parallel slices ids := make([]int64, len(operations)) operationTypes := make([]string, len(operations)) - operationXDRs := make([]string, len(operations)) + operationXDRs := make([][]byte, len(operations)) resultCodes := make([]string, len(operations)) successfulFlags := make([]bool, len(operations)) ledgerNumbers := make([]uint32, len(operations)) @@ -293,7 +293,7 @@ func (m *OperationModel) BatchInsert( for i, op := range operations { ids[i] = op.ID operationTypes[i] = string(op.OperationType) - operationXDRs[i] = op.OperationXDR + operationXDRs[i] = []byte(op.OperationXDR) resultCodes[i] = op.ResultCode successfulFlags[i] = op.Successful ledgerNumbers[i] = op.LedgerNumber @@ -331,7 +331,7 @@ func (m *OperationModel) BatchInsert( SELECT UNNEST($1::bigint[]) AS id, UNNEST($2::text[]) AS operation_type, - UNNEST($3::text[]) AS operation_xdr, + UNNEST($3::bytea[]) AS operation_xdr, UNNEST($4::text[]) AS result_code, UNNEST($5::boolean[]) AS successful, UNNEST($6::bigint[]) AS ledger_number, @@ -422,7 +422,7 @@ func (m *OperationModel) BatchCopy( return []any{ pgtype.Int8{Int64: op.ID, Valid: true}, pgtype.Text{String: string(op.OperationType), Valid: true}, - pgtype.Text{String: op.OperationXDR, Valid: true}, + []byte(op.OperationXDR), pgtype.Text{String: op.ResultCode, Valid: true}, pgtype.Bool{Bool: op.Successful, Valid: true}, pgtype.Int4{Int32: int32(op.LedgerNumber), Valid: true}, diff --git a/internal/data/operations_test.go b/internal/data/operations_test.go index fb8f64a3..40a5181e 100644 --- a/internal/data/operations_test.go +++ b/internal/data/operations_test.go @@ -33,7 +33,7 @@ func generateTestOperations(n int, startID int64) ([]*types.Operation, map[int64 ops[i] = &types.Operation{ ID: opID, OperationType: types.OperationTypePayment, - OperationXDR: fmt.Sprintf("operation_xdr_%d", i), + OperationXDR: types.XDRBytea([]byte(fmt.Sprintf("operation_xdr_%d", i))), LedgerNumber: uint32(i + 1), LedgerCreatedAt: now, } @@ -65,7 +65,7 @@ func Test_OperationModel_BatchInsert(t *testing.T) { meta1, meta2 := "meta1", "meta2" envelope1, envelope2 := "envelope1", "envelope2" tx1 := types.Transaction{ - Hash: "tx1", + Hash: "d176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 4096, EnvelopeXDR: &envelope1, FeeCharged: 100, @@ -76,7 +76,7 @@ func Test_OperationModel_BatchInsert(t *testing.T) { IsFeeBump: false, } tx2 := types.Transaction{ - Hash: "tx2", + Hash: "e176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 8192, EnvelopeXDR: &envelope2, FeeCharged: 200, @@ -101,13 +101,13 @@ func Test_OperationModel_BatchInsert(t *testing.T) { op1 := types.Operation{ ID: 4097, // in range (4096, 8192) OperationType: types.OperationTypePayment, - OperationXDR: "operation1", + OperationXDR: types.XDRBytea([]byte("operation1")), LedgerCreatedAt: now, } op2 := types.Operation{ ID: 8193, // in range (8192, 12288) OperationType: types.OperationTypeCreateAccount, - OperationXDR: "operation2", + OperationXDR: types.XDRBytea([]byte("operation2")), LedgerCreatedAt: now, } @@ -252,7 +252,7 @@ func Test_OperationModel_BatchCopy(t *testing.T) { meta1, meta2 := "meta1", "meta2" envelope1, envelope2 := "envelope1", "envelope2" tx1 := types.Transaction{ - Hash: "tx1", + Hash: "d176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 4096, EnvelopeXDR: &envelope1, FeeCharged: 100, @@ -263,7 +263,7 @@ func Test_OperationModel_BatchCopy(t *testing.T) { IsFeeBump: false, } tx2 := types.Transaction{ - Hash: "tx2", + Hash: "e176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 8192, EnvelopeXDR: &envelope2, FeeCharged: 200, @@ -288,13 +288,13 @@ func Test_OperationModel_BatchCopy(t *testing.T) { op1 := types.Operation{ ID: 4097, // in range (4096, 8192) OperationType: types.OperationTypePayment, - OperationXDR: "operation1", + OperationXDR: types.XDRBytea([]byte("operation1")), LedgerCreatedAt: now, } op2 := types.Operation{ ID: 8193, // in range (8192, 12288) OperationType: types.OperationTypeCreateAccount, - OperationXDR: "operation2", + OperationXDR: types.XDRBytea([]byte("operation2")), LedgerCreatedAt: now, } @@ -432,7 +432,7 @@ func Test_OperationModel_BatchCopy_DuplicateFails(t *testing.T) { op1 := types.Operation{ ID: 999, OperationType: types.OperationTypePayment, - OperationXDR: "operation_xdr_dup_test", + OperationXDR: types.XDRBytea([]byte("operation_xdr_dup_test")), LedgerNumber: 1, LedgerCreatedAt: now, } @@ -502,24 +502,30 @@ func TestOperationModel_GetAll(t *testing.T) { ctx := context.Background() now := time.Now() - // Create test transactions first + // Create test transactions first (hash is BYTEA, using valid 64-char hex strings) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test operations (IDs must be in TOID range for each transaction: (to_id, to_id + 4096)) + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + xdr3 := types.XDRBytea([]byte("xdr3")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (2, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (4098, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1), - (8194, 'PAYMENT', 'xdr3', 'op_success', true, 3, $1) - `, now) + (2, 'PAYMENT', $2, 'op_success', true, 1, $1), + (4098, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), + (8194, 'PAYMENT', $4, 'op_success', true, 3, $1) + `, now, xdr1, xdr2, xdr3) require.NoError(t, err) // Test GetAll without limit (gets all operations) @@ -552,29 +558,38 @@ func TestOperationModel_BatchGetByToIDs(t *testing.T) { // Create test transactions first with specific ToIDs // ToID encoding: operations for a tx with to_id are in range (to_id, to_id + 4096) // Using to_id values: 4096, 8192, 12288 (multiples of 4096 for clarity) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 12288, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 12288, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test operations - IDs must be in TOID range for each transaction // For tx1 (to_id=4096): ops 4097, 4098, 4099 // For tx2 (to_id=8192): ops 8193, 8194 // For tx3 (to_id=12288): op 12289 + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + xdr3 := types.XDRBytea([]byte("xdr3")) + xdr4 := types.XDRBytea([]byte("xdr4")) + xdr5 := types.XDRBytea([]byte("xdr5")) + xdr6 := types.XDRBytea([]byte("xdr6")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (8193, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1), - (4098, 'PAYMENT', 'xdr3', 'op_success', true, 3, $1), - (4099, 'MANAGE_SELL_OFFER', 'xdr4', 'op_success', true, 4, $1), - (8194, 'PAYMENT', 'xdr5', 'op_success', true, 5, $1), - (12289, 'CHANGE_TRUST', 'xdr6', 'op_success', true, 6, $1) - `, now) + (4097, 'PAYMENT', $2, 'op_success', true, 1, $1), + (8193, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), + (4098, 'PAYMENT', $4, 'op_success', true, 3, $1), + (4099, 'MANAGE_SELL_OFFER', $5, 'op_success', true, 4, $1), + (8194, 'PAYMENT', $6, 'op_success', true, 5, $1), + (12289, 'CHANGE_TRUST', $7, 'op_success', true, 6, $1) + `, now, xdr1, xdr2, xdr3, xdr4, xdr5, xdr6) require.NoError(t, err) testCases := []struct { @@ -742,33 +757,38 @@ func TestOperationModel_BatchGetByToID(t *testing.T) { ctx := context.Background() now := time.Now() - // Create test transactions first with specific ToIDs + // Create test transactions first with specific ToIDs (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true) - `, now) + ($2, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true) + `, now, testHash1, testHash2) require.NoError(t, err) // Create test operations - IDs must be in TOID range for each transaction // For tx1 (to_id=4096): ops 4097, 4098 // For tx2 (to_id=8192): op 8193 + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + xdr3 := types.XDRBytea([]byte("xdr3")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (8193, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1), - (4098, 'PAYMENT', 'xdr3', 'op_success', true, 3, $1) - `, now) + (4097, 'PAYMENT', $2, 'op_success', true, 1, $1), + (8193, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), + (4098, 'PAYMENT', $4, 'op_success', true, 3, $1) + `, now, xdr1, xdr2, xdr3) require.NoError(t, err) // Test BatchGetByToID operations, err := m.BatchGetByToID(ctx, 4096, "", nil, nil, ASC) require.NoError(t, err) assert.Len(t, operations, 2) - assert.Equal(t, "xdr1", operations[0].OperationXDR) - assert.Equal(t, "xdr3", operations[1].OperationXDR) + assert.Equal(t, xdr1.String(), operations[0].OperationXDR.String()) + assert.Equal(t, xdr3.String(), operations[1].OperationXDR.String()) } func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { @@ -797,24 +817,30 @@ func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1), ($2)", address1, address2) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 12288, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 12288, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test operations (IDs must be in TOID range for each transaction) + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + xdr3 := types.XDRBytea([]byte("xdr3")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (8193, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1), - (12289, 'PAYMENT', 'xdr3', 'op_success', true, 3, $1) - `, now) + (4097, 'PAYMENT', $2, 'op_success', true, 1, $1), + (8193, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), + (12289, 'PAYMENT', $4, 'op_success', true, 3, $1) + `, now, xdr1, xdr2, xdr3) require.NoError(t, err) // Create test operations_accounts links @@ -845,22 +871,26 @@ func TestOperationModel_GetByID(t *testing.T) { ctx := context.Background() now := time.Now() - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true) - `, now) + ($2, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true) + `, now, testHash1, testHash2) require.NoError(t, err) // Create test operations (IDs must be in TOID range for each transaction) + opXdr1 := types.XDRBytea([]byte("xdr1")) + opXdr2 := types.XDRBytea([]byte("xdr2")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (8193, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1) - `, now) + (4097, 'PAYMENT', $2, 'op_success', true, 1, $1), + (8193, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1) + `, now, opXdr1, opXdr2) require.NoError(t, err) mockMetricsService := metrics.NewMockMetricsService() @@ -876,7 +906,7 @@ func TestOperationModel_GetByID(t *testing.T) { operation, err := m.GetByID(ctx, 4097, "") require.NoError(t, err) assert.Equal(t, int64(4097), operation.ID) - assert.Equal(t, "xdr1", operation.OperationXDR) + assert.Equal(t, opXdr1.String(), operation.OperationXDR.String()) assert.Equal(t, uint32(1), operation.LedgerNumber) assert.WithinDuration(t, now, operation.LedgerCreatedAt, time.Second) } @@ -907,24 +937,30 @@ func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", address) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 12288, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 4096, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 8192, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 12288, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test operations (IDs must be in TOID range for each transaction) + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + xdr3 := types.XDRBytea([]byte("xdr3")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (8193, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1), - (12289, 'PAYMENT', 'xdr3', 'op_success', true, 3, $1) - `, now) + (4097, 'PAYMENT', $2, 'op_success', true, 1, $1), + (8193, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), + (12289, 'PAYMENT', $4, 'op_success', true, 3, $1) + `, now, xdr1, xdr2, xdr3) require.NoError(t, err) // Create test state changes diff --git a/internal/data/statechanges.go b/internal/data/statechanges.go index 78db7c90..691e1121 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -38,7 +38,7 @@ func (m *StateChangeModel) BatchGetByAccountAddress(ctx context.Context, account // Add transaction hash filter if provided (uses subquery to find to_id by hash) if txHash != nil { queryBuilder.WriteString(fmt.Sprintf(" AND to_id = (SELECT to_id FROM transactions WHERE hash = $%d)", argIndex)) - args = append(args, *txHash) + args = append(args, types.HashBytea(*txHash)) argIndex++ } @@ -186,7 +186,7 @@ func (m *StateChangeModel) BatchInsert( ledgerNumbers := make([]int, len(stateChanges)) accountIDBytes := make([][]byte, len(stateChanges)) operationIDs := make([]int64, len(stateChanges)) - tokenIDs := make([]*string, len(stateChanges)) + tokenIDBytes := make([][]byte, len(stateChanges)) amounts := make([]*string, len(stateChanges)) signerAccountIDBytes := make([][]byte, len(stateChanges)) spenderAccountIDBytes := make([][]byte, len(stateChanges)) @@ -233,8 +233,9 @@ func (m *StateChangeModel) BatchInsert( reason := string(*sc.StateChangeReason) reasons[i] = &reason } - if sc.TokenID.Valid { - tokenIDs[i] = &sc.TokenID.String + tokenIDBytes[i], err = pgtypeBytesFromNullAddressBytea(sc.TokenID) + if err != nil { + return nil, fmt.Errorf("converting token_id: %w", err) } if sc.Amount.Valid { amounts[i] = &sc.Amount.String @@ -268,9 +269,6 @@ func (m *StateChangeModel) BatchInsert( if sc.ClaimableBalanceID.Valid { claimableBalanceIDs[i] = &sc.ClaimableBalanceID.String } - if sc.ClaimableBalanceID.Valid { - claimableBalanceIDs[i] = &sc.ClaimableBalanceID.String - } if sc.LiquidityPoolID.Valid { liquidityPoolIDs[i] = &sc.LiquidityPoolID.String } @@ -315,7 +313,7 @@ func (m *StateChangeModel) BatchInsert( UNNEST($6::integer[]) AS ledger_number, UNNEST($7::bytea[]) AS account_id, UNNEST($8::bigint[]) AS operation_id, - UNNEST($9::text[]) AS token_id, + UNNEST($9::bytea[]) AS token_id, UNNEST($10::text[]) AS amount, UNNEST($11::bytea[]) AS signer_account_id, UNNEST($12::bytea[]) AS spender_account_id, @@ -368,7 +366,7 @@ func (m *StateChangeModel) BatchInsert( pq.Array(ledgerNumbers), pq.Array(accountIDBytes), pq.Array(operationIDs), - pq.Array(tokenIDs), + pq.Array(tokenIDBytes), pq.Array(amounts), pq.Array(signerAccountIDBytes), pq.Array(spenderAccountIDBytes), @@ -466,6 +464,10 @@ func (m *StateChangeModel) BatchCopy( if err != nil { return nil, fmt.Errorf("converting funder_account_id: %w", err) } + tokenBytes, err := pgtypeBytesFromNullAddressBytea(sc.TokenID) + if err != nil { + return nil, fmt.Errorf("converting token_id: %w", err) + } return []any{ pgtype.Int8{Int64: sc.ToID, Valid: true}, @@ -476,7 +478,7 @@ func (m *StateChangeModel) BatchCopy( pgtype.Int4{Int32: int32(sc.LedgerNumber), Valid: true}, accountBytes, pgtype.Int8{Int64: sc.OperationID, Valid: true}, - pgtypeTextFromNullString(sc.TokenID), + tokenBytes, pgtypeTextFromNullString(sc.Amount), signerBytes, spenderBytes, @@ -530,12 +532,16 @@ func (m *StateChangeModel) BatchGetByToID(ctx context.Context, toID int64, colum if cursor != nil { if sortOrder == DESC { queryBuilder.WriteString(fmt.Sprintf(` - AND (to_id, operation_id, state_change_order) < (%d, %d, %d) - `, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder)) + AND (to_id, operation_id, state_change_order) < ($%d, $%d, $%d) + `, argIndex, argIndex+1, argIndex+2)) + args = append(args, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder) + argIndex += 3 } else { queryBuilder.WriteString(fmt.Sprintf(` - AND (to_id, operation_id, state_change_order) > (%d, %d, %d) - `, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder)) + AND (to_id, operation_id, state_change_order) > ($%d, $%d, $%d) + `, argIndex, argIndex+1, argIndex+2)) + args = append(args, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder) + argIndex += 3 } } @@ -640,12 +646,16 @@ func (m *StateChangeModel) BatchGetByOperationID(ctx context.Context, operationI if cursor != nil { if sortOrder == DESC { queryBuilder.WriteString(fmt.Sprintf(` - AND (to_id, operation_id, state_change_order) < (%d, %d, %d) - `, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder)) + AND (to_id, operation_id, state_change_order) < ($%d, $%d, $%d) + `, argIndex, argIndex+1, argIndex+2)) + args = append(args, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder) + argIndex += 3 } else { queryBuilder.WriteString(fmt.Sprintf(` - AND (to_id, operation_id, state_change_order) > (%d, %d, %d) - `, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder)) + AND (to_id, operation_id, state_change_order) > ($%d, $%d, $%d) + `, argIndex, argIndex+1, argIndex+2)) + args = append(args, cursor.ToID, cursor.OperationID, cursor.StateChangeOrder) + argIndex += 3 } } diff --git a/internal/data/statechanges_test.go b/internal/data/statechanges_test.go index 59c10ab7..b0bc2020 100644 --- a/internal/data/statechanges_test.go +++ b/internal/data/statechanges_test.go @@ -40,8 +40,8 @@ func generateTestStateChanges(n int, accountID string, startToID int64, auxAddre LedgerNumber: uint32(i + 1), AccountID: types.AddressBytea(accountID), OperationID: int64(i + 1), - // sql.NullString fields - TokenID: sql.NullString{String: fmt.Sprintf("token_%d", i), Valid: true}, + // NullAddressBytea token field + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[(auxIdx+6)%len(auxAddresses)]), Valid: true}, Amount: sql.NullString{String: fmt.Sprintf("%d", (i+1)*100), Valid: true}, // NullAddressBytea fields SignerAccountID: types.NullAddressBytea{AddressBytea: types.AddressBytea(auxAddresses[auxIdx]), Valid: true}, @@ -86,7 +86,7 @@ func TestStateChangeModel_BatchInsert(t *testing.T) { meta1, meta2 := "meta1", "meta2" envelope1, envelope2 := "envelope1", "envelope2" tx1 := types.Transaction{ - Hash: "tx1", + Hash: "f176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 1, EnvelopeXDR: &envelope1, FeeCharged: 100, @@ -97,7 +97,7 @@ func TestStateChangeModel_BatchInsert(t *testing.T) { IsFeeBump: false, } tx2 := types.Transaction{ - Hash: "tx2", + Hash: "0276b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 2, EnvelopeXDR: &envelope2, FeeCharged: 200, @@ -126,7 +126,7 @@ func TestStateChangeModel_BatchInsert(t *testing.T) { LedgerNumber: 1, AccountID: types.AddressBytea(kp1.Address()), OperationID: 123, - TokenID: sql.NullString{String: "token1", Valid: true}, + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(kp1.Address()), Valid: true}, Amount: sql.NullString{String: "100", Valid: true}, } sc2 := types.StateChange{ @@ -242,7 +242,7 @@ func TestStateChangeModel_BatchCopy(t *testing.T) { meta1, meta2 := "meta1", "meta2" envelope1, envelope2 := "envelope1", "envelope2" tx1 := types.Transaction{ - Hash: "tx1", + Hash: "f176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 1, EnvelopeXDR: &envelope1, FeeCharged: 100, @@ -253,7 +253,7 @@ func TestStateChangeModel_BatchCopy(t *testing.T) { IsFeeBump: false, } tx2 := types.Transaction{ - Hash: "tx2", + Hash: "0276b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 2, EnvelopeXDR: &envelope2, FeeCharged: 200, @@ -282,7 +282,7 @@ func TestStateChangeModel_BatchCopy(t *testing.T) { LedgerNumber: 1, AccountID: types.AddressBytea(kp1.Address()), OperationID: 123, - TokenID: sql.NullString{String: "token1", Valid: true}, + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(kp1.Address()), Valid: true}, Amount: sql.NullString{String: "100", Valid: true}, } sc2 := types.StateChange{ @@ -487,14 +487,17 @@ func TestStateChangeModel_BatchGetByAccountAddress(t *testing.T) { types.AddressBytea(address1), types.AddressBytea(address2)) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test state changes @@ -549,14 +552,18 @@ func TestStateChangeModel_BatchGetByAccountAddress_WithFilters(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", types.AddressBytea(address)) require.NoError(t, err) - // Create test transactions + // Create test transactions (hash is BYTEA) + testHash1 := "0000000000000000000000000000000000000000000000000000000000000001" + testHash2 := "0000000000000000000000000000000000000000000000000000000000000002" + testHash3 := "0000000000000000000000000000000000000000000000000000000000000003" + testHashNonExistent := "0000000000000000000000000000000000000000000000000000000000000004" _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1), - ('tx3', 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1), + ($4, 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1) + `, now, types.HashBytea(testHash1), types.HashBytea(testHash2), types.HashBytea(testHash3)) require.NoError(t, err) // Create test state changes with different operation IDs, categories, and reasons @@ -583,7 +590,7 @@ func TestStateChangeModel_BatchGetByAccountAddress_WithFilters(t *testing.T) { MetricsService: mockMetricsService, } - txHash := "tx1" + txHash := testHash1 stateChanges, err := m.BatchGetByAccountAddress(ctx, address, &txHash, nil, nil, nil, "", nil, nil, ASC) require.NoError(t, err) // tx1 has to_id=1, so we get state changes where to_id=1 (2 state changes now) @@ -627,7 +634,7 @@ func TestStateChangeModel_BatchGetByAccountAddress_WithFilters(t *testing.T) { MetricsService: mockMetricsService, } - txHash := "tx1" + txHash := testHash1 operationID := int64(123) stateChanges, err := m.BatchGetByAccountAddress(ctx, address, &txHash, &operationID, nil, nil, "", nil, nil, ASC) require.NoError(t, err) @@ -716,7 +723,7 @@ func TestStateChangeModel_BatchGetByAccountAddress_WithFilters(t *testing.T) { MetricsService: mockMetricsService, } - txHash := "tx1" + txHash := testHash1 operationID := int64(123) category := "BALANCE" reason := "CREDIT" @@ -743,7 +750,7 @@ func TestStateChangeModel_BatchGetByAccountAddress_WithFilters(t *testing.T) { MetricsService: mockMetricsService, } - txHash := "nonexistent" + txHash := testHashNonExistent stateChanges, err := m.BatchGetByAccountAddress(ctx, address, &txHash, nil, nil, nil, "", nil, nil, ASC) require.NoError(t, err) assert.Empty(t, stateChanges) @@ -760,7 +767,7 @@ func TestStateChangeModel_BatchGetByAccountAddress_WithFilters(t *testing.T) { MetricsService: mockMetricsService, } - txHash := "tx1" + txHash := testHash1 limit := int32(1) stateChanges, err := m.BatchGetByAccountAddress(ctx, address, &txHash, nil, nil, nil, "", &limit, nil, ASC) require.NoError(t, err) @@ -794,14 +801,17 @@ func TestStateChangeModel_GetAll(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", types.AddressBytea(address)) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test state changes @@ -841,14 +851,17 @@ func TestStateChangeModel_BatchGetByToIDs(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", types.AddressBytea(address)) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test state changes - multiple state changes per to_id to test ranking @@ -997,14 +1010,17 @@ func TestStateChangeModel_BatchGetByOperationIDs(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", types.AddressBytea(address)) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") + testHash3 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000003") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), + ($4, 3, 'env3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) + `, now, testHash1, testHash2, testHash3) require.NoError(t, err) // Create test state changes @@ -1057,13 +1073,15 @@ func TestStateChangeModel_BatchGetByToID(t *testing.T) { _, err = dbConnectionPool.ExecContext(ctx, "INSERT INTO accounts (stellar_address) VALUES ($1)", types.AddressBytea(address)) require.NoError(t, err) - // Create test transactions first + // Create test transactions first (hash is BYTEA) + testHash1 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000001") + testHash2 := types.HashBytea("0000000000000000000000000000000000000000000000000000000000000002") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true) - `, now) + ($2, 1, 'env1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), + ($3, 2, 'env2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true) + `, now, testHash1, testHash2) require.NoError(t, err) // Create test state changes for to_id=1 (multiple state_change_orders) diff --git a/internal/data/transactions.go b/internal/data/transactions.go index 6ce4edaa..efa39497 100644 --- a/internal/data/transactions.go +++ b/internal/data/transactions.go @@ -27,7 +27,8 @@ func (m *TransactionModel) GetByHash(ctx context.Context, hash string, columns s query := fmt.Sprintf(`SELECT %s FROM transactions WHERE hash = $1`, columns) var transaction types.Transaction start := time.Now() - err := m.DB.GetContext(ctx, &transaction, query, hash) + hashBytea := types.HashBytea(hash) + err := m.DB.GetContext(ctx, &transaction, query, hashBytea) duration := time.Since(start).Seconds() m.MetricsService.ObserveDBQueryDuration("GetByHash", "transactions", duration) if err != nil { @@ -190,7 +191,7 @@ func (m *TransactionModel) BatchInsert( } // 1. Flatten the transactions into parallel slices - hashes := make([]string, len(txs)) + hashes := make([][]byte, len(txs)) toIDs := make([]int64, len(txs)) envelopeXDRs := make([]*string, len(txs)) feesCharged := make([]int64, len(txs)) @@ -201,7 +202,11 @@ func (m *TransactionModel) BatchInsert( isFeeBumps := make([]bool, len(txs)) for i, t := range txs { - hashes[i] = t.Hash + hashBytes, err := t.Hash.Value() + if err != nil { + return nil, fmt.Errorf("converting hash %s to bytes: %w", t.Hash, err) + } + hashes[i] = hashBytes.([]byte) toIDs[i] = t.ToID envelopeXDRs[i] = t.EnvelopeXDR feesCharged[i] = t.FeeCharged @@ -244,7 +249,7 @@ func (m *TransactionModel) BatchInsert( t.hash, t.to_id, t.envelope_xdr, t.fee_charged, t.result_code, t.meta_xdr, t.ledger_number, t.ledger_created_at, t.is_fee_bump FROM ( SELECT - UNNEST($1::text[]) AS hash, + UNNEST($1::bytea[]) AS hash, UNNEST($2::bigint[]) AS to_id, UNNEST($3::text[]) AS envelope_xdr, UNNEST($4::bigint[]) AS fee_charged, @@ -277,7 +282,7 @@ func (m *TransactionModel) BatchInsert( ` start := time.Now() - var insertedHashes []string + var insertedHashes []types.HashBytea err := sqlExecuter.SelectContext(ctx, &insertedHashes, insertQuery, pq.Array(hashes), pq.Array(toIDs), @@ -308,7 +313,13 @@ func (m *TransactionModel) BatchInsert( return nil, fmt.Errorf("batch inserting transactions and transactions_accounts: %w", err) } - return insertedHashes, nil + // Convert HashBytea to string for the return value + result := make([]string, len(insertedHashes)) + for i, h := range insertedHashes { + result[i] = h.String() + } + + return result, nil } // BatchCopy inserts transactions using pgx's binary COPY protocol. @@ -338,8 +349,12 @@ func (m *TransactionModel) BatchCopy( []string{"hash", "to_id", "envelope_xdr", "fee_charged", "result_code", "meta_xdr", "ledger_number", "ledger_created_at", "is_fee_bump"}, pgx.CopyFromSlice(len(txs), func(i int) ([]any, error) { tx := txs[i] + hashBytes, err := tx.Hash.Value() + if err != nil { + return nil, fmt.Errorf("converting hash %s to bytes: %w", tx.Hash, err) + } return []any{ - pgtype.Text{String: tx.Hash, Valid: true}, + hashBytes, pgtype.Int8{Int64: tx.ToID, Valid: true}, pgtypeTextFromPtr(tx.EnvelopeXDR), pgtype.Int8{Int64: tx.FeeCharged, Valid: true}, diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 34bd6a40..489e5691 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -31,13 +31,13 @@ func generateTestTransactions(n int, startLedger int32) ([]*types.Transaction, m ledgerSeq := startLedger + int32(i) txIndex := int32(1) // First transaction in each ledger toID := toid.New(ledgerSeq, txIndex, 0).ToInt64() - hash := fmt.Sprintf("e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760-%d", toID) + hash := fmt.Sprintf("e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0%08x", i) envelope := "AAAAAgAAAAB/NpQ+s+cP+ztX7ryuKgXrxowZPHd4qAxhseOye/JeUgAehIAC2NL/AAflugAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAwAAAAFQQUxMAAAAAKHc4IKbcW8HPPgy3zOhuqv851y72nfLGa0HVXxIRNzHAAAAAAAAAAAAQ3FwMxshxQAfwV8AAAAAYTGQ3QAAAAAAAAAMAAAAAAAAAAFQQUxMAAAAAKHc4IKbcW8HPPgy3zOhuqv851y72nfLGa0HVXxIRNzHAAAAAAAGXwFksiHwAEXz8QAAAABhoaQjAAAAAAAAAAF78l5SAAAAQD7LgvZA8Pdvfh5L2b9B9RC7DlacGBJuOchuZDHQdVD1P0bn6nGQJXxDDI4oN76J49JxB7bIgDVim39MU43MOgE=" meta := "AAAAAwAAAAAAAAAEAAAAAwM6nhwAAAAAAAAAAJjy0MY1CPlZ/co80nzufVmo4gd7NqWMb+RiGiPhiviJAAAAC4SozKUDMWgAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAQM6nhwAAAAAAAAAAJjy0MY1CPlZ/co80nzufVmo4gd7NqWMb+RiGiPhiviJAAAAC4SozKUDMWgAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAwM6LTkAAAAAAAAAAKl6DQcpepRdTbO/Vw4hYBENfE/95GevM7SNA0ftK0gtAAAAA8Kuf0AC+zZCAAAATAAAAAMAAAABAAAAAMRxxkNwYslQaok0LlOKGtpATS9Bzx06JV9DIffG4OF1AAAAAAAAAAlsb2JzdHIuY28AAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAAyZ54QAAAABmrTXCAAAAAAAAAAEDOp4cAAAAAAAAAACpeg0HKXqUXU2zv1cOIWARDXxP/eRnrzO0jQNH7StILQAAAAPCrn9AAvs2QgAAAE0AAAADAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAM6nhwAAAAAZyGdHwAAAAAAAAABAAAABAAAAAMDOp4cAAAAAAAAAACpeg0HKXqUXU2zv1cOIWARDXxP/eRnrzO0jQNH7StILQAAAAPCrn9AAvs2QgAAAE0AAAADAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAM6nhwAAAAAZyGdHwAAAAAAAAABAzqeHAAAAAAAAAAAqXoNByl6lF1Ns79XDiFgEQ18T/3kZ68ztI0DR+0rSC0AAAACmKiNQAL7NkIAAABNAAAAAwAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADOp4cAAAAAGchnR8AAAAAAAAAAwM6nZoAAAAAAAAAALKxMozkOH3rgpz3/u3+93wsR4p6z4K82HmJ5NTuaZbYAAACZaqAwoIBqycyAABVlQAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAAzqSNgAAAABnIVdaAAAAAAAAAAEDOp4cAAAAAAAAAACysTKM5Dh964Kc9/7t/vd8LEeKes+CvNh5ieTU7mmW2AAAAmbUhrSCAasnMgAAVZUAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAM6kjYAAAAAZyFXWgAAAAAAAAAAAAAAAA==" address := keypair.MustRandom().Address() txs[i] = &types.Transaction{ - Hash: hash, + Hash: types.HashBytea(hash), ToID: toID, EnvelopeXDR: &envelope, FeeCharged: int64(100 * (i + 1)), @@ -73,7 +73,7 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { meta1, meta2 := "meta1", "meta2" envelope1, envelope2 := "envelope1", "envelope2" tx1 := types.Transaction{ - Hash: "tx1", + Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1, EnvelopeXDR: &envelope1, FeeCharged: 100, @@ -84,7 +84,7 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { IsFeeBump: false, } tx2 := types.Transaction{ - Hash: "tx2", + Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2, EnvelopeXDR: &envelope2, FeeCharged: 200, @@ -111,7 +111,7 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { stellarAddressesByToID: map[int64]set.Set[string]{tx1.ToID: set.NewSet(kp1.Address()), tx2.ToID: set.NewSet(kp2.Address())}, wantAccountLinks: map[int64][]string{tx1.ToID: {kp1.Address()}, tx2.ToID: {kp2.Address()}}, wantErrContains: "", - wantHashes: []string{tx1.Hash, tx2.Hash}, + wantHashes: []string{tx1.Hash.String(), tx2.Hash.String()}, }, { name: "🟢successful_insert_with_dbTx", @@ -120,7 +120,7 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { stellarAddressesByToID: map[int64]set.Set[string]{tx1.ToID: set.NewSet(kp1.Address())}, wantAccountLinks: map[int64][]string{tx1.ToID: {kp1.Address()}}, wantErrContains: "", - wantHashes: []string{tx1.Hash}, + wantHashes: []string{tx1.Hash.String()}, }, { name: "🟢empty_input", @@ -138,7 +138,7 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { stellarAddressesByToID: map[int64]set.Set[string]{tx1.ToID: set.NewSet(kp1.Address())}, wantAccountLinks: map[int64][]string{tx1.ToID: {kp1.Address()}}, wantErrContains: "", - wantHashes: []string{tx1.Hash}, + wantHashes: []string{tx1.Hash.String()}, }, } @@ -185,10 +185,15 @@ func Test_TransactionModel_BatchInsert(t *testing.T) { // Verify the results require.NoError(t, err) - var dbInsertedHashes []string + var dbInsertedHashes []types.HashBytea err = sqlExecuter.SelectContext(ctx, &dbInsertedHashes, "SELECT hash FROM transactions") require.NoError(t, err) - assert.ElementsMatch(t, tc.wantHashes, dbInsertedHashes) + // Convert HashBytea to string for comparison + dbHashStrings := make([]string, len(dbInsertedHashes)) + for i, h := range dbInsertedHashes { + dbHashStrings[i] = h.String() + } + assert.ElementsMatch(t, tc.wantHashes, dbHashStrings) assert.ElementsMatch(t, tc.wantHashes, gotInsertedHashes) // Verify the account links @@ -237,8 +242,8 @@ func Test_TransactionModel_BatchCopy(t *testing.T) { meta1, meta2 := "meta1", "meta2" envelope1, envelope2 := "envelope1", "envelope2" - tx1 := types.Transaction{ - Hash: "tx1", + txCopy1 := types.Transaction{ + Hash: "b76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48762", ToID: 1, EnvelopeXDR: &envelope1, FeeCharged: 100, @@ -248,8 +253,8 @@ func Test_TransactionModel_BatchCopy(t *testing.T) { LedgerCreatedAt: now, IsFeeBump: false, } - tx2 := types.Transaction{ - Hash: "tx2", + txCopy2 := types.Transaction{ + Hash: "c76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48763", ToID: 2, EnvelopeXDR: &envelope2, FeeCharged: 200, @@ -260,8 +265,8 @@ func Test_TransactionModel_BatchCopy(t *testing.T) { IsFeeBump: true, } // Transaction with nullable fields (nil envelope and meta) - tx3 := types.Transaction{ - Hash: "tx3", + txCopy3 := types.Transaction{ + Hash: "d76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48764", ToID: 3, EnvelopeXDR: nil, FeeCharged: 300, @@ -281,8 +286,8 @@ func Test_TransactionModel_BatchCopy(t *testing.T) { }{ { name: "🟢successful_insert_multiple", - txs: []*types.Transaction{&tx1, &tx2}, - stellarAddressesByToID: map[int64]set.Set[string]{tx1.ToID: set.NewSet(kp1.Address()), tx2.ToID: set.NewSet(kp2.Address())}, + txs: []*types.Transaction{&txCopy1, &txCopy2}, + stellarAddressesByToID: map[int64]set.Set[string]{txCopy1.ToID: set.NewSet(kp1.Address()), txCopy2.ToID: set.NewSet(kp2.Address())}, wantCount: 2, }, { @@ -293,13 +298,13 @@ func Test_TransactionModel_BatchCopy(t *testing.T) { }, { name: "🟢nullable_fields", - txs: []*types.Transaction{&tx3}, - stellarAddressesByToID: map[int64]set.Set[string]{tx3.ToID: set.NewSet(kp1.Address())}, + txs: []*types.Transaction{&txCopy3}, + stellarAddressesByToID: map[int64]set.Set[string]{txCopy3.ToID: set.NewSet(kp1.Address())}, wantCount: 1, }, { name: "🟢no_participants", - txs: []*types.Transaction{&tx1}, + txs: []*types.Transaction{&txCopy1}, stellarAddressesByToID: map[int64]set.Set[string]{}, wantCount: 1, }, @@ -356,7 +361,7 @@ func Test_TransactionModel_BatchCopy(t *testing.T) { assert.Equal(t, tc.wantCount, gotCount) // Verify from DB - var dbInsertedHashes []string + var dbInsertedHashes []types.HashBytea err = dbConnectionPool.SelectContext(ctx, &dbInsertedHashes, "SELECT hash FROM transactions ORDER BY hash") require.NoError(t, err) assert.Len(t, dbInsertedHashes, tc.wantCount) @@ -404,8 +409,8 @@ func Test_TransactionModel_BatchCopy_DuplicateFails(t *testing.T) { meta := "meta1" envelope := "envelope1" - tx1 := types.Transaction{ - Hash: "tx_duplicate_test", + txDup := types.Transaction{ + Hash: "f76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48766", ToID: 100, EnvelopeXDR: &envelope, FeeCharged: 100, @@ -420,14 +425,14 @@ func Test_TransactionModel_BatchCopy_DuplicateFails(t *testing.T) { sqlxDB, err := dbConnectionPool.SqlxDB(ctx) require.NoError(t, err) txModel := &TransactionModel{DB: dbConnectionPool, MetricsService: metrics.NewMetricsService(sqlxDB)} - _, err = txModel.BatchInsert(ctx, nil, []*types.Transaction{&tx1}, map[int64]set.Set[string]{ - tx1.ToID: set.NewSet(kp1.Address()), + _, err = txModel.BatchInsert(ctx, nil, []*types.Transaction{&txDup}, map[int64]set.Set[string]{ + txDup.ToID: set.NewSet(kp1.Address()), }) require.NoError(t, err) // Verify the transaction was inserted var count int - err = dbConnectionPool.GetContext(ctx, &count, "SELECT COUNT(*) FROM transactions WHERE hash = $1", tx1.Hash) + err = dbConnectionPool.GetContext(ctx, &count, "SELECT COUNT(*) FROM transactions WHERE hash = $1", txDup.Hash) require.NoError(t, err) require.Equal(t, 1, count) @@ -449,8 +454,8 @@ func Test_TransactionModel_BatchCopy_DuplicateFails(t *testing.T) { pgxTx, err := conn.Begin(ctx) require.NoError(t, err) - _, err = m.BatchCopy(ctx, pgxTx, []*types.Transaction{&tx1}, map[int64]set.Set[string]{ - tx1.ToID: set.NewSet(kp1.Address()), + _, err = m.BatchCopy(ctx, pgxTx, []*types.Transaction{&txDup}, map[int64]set.Set[string]{ + txDup.ToID: set.NewSet(kp1.Address()), }) // BatchCopy should fail with a unique constraint violation @@ -482,7 +487,7 @@ func TestTransactionModel_GetByHash(t *testing.T) { now := time.Now() // Create test transaction - txHash := "test_tx_hash" + txHash := types.HashBytea("0076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES ($1, 1, 'envelope', 100, 'TransactionResultCodeTxSuccess', 'meta', 1, $2, false) @@ -490,7 +495,7 @@ func TestTransactionModel_GetByHash(t *testing.T) { require.NoError(t, err) // Test GetByHash - transaction, err := m.GetByHash(ctx, txHash, "") + transaction, err := m.GetByHash(ctx, txHash.String(), "") require.NoError(t, err) assert.Equal(t, txHash, transaction.Hash) assert.Equal(t, int64(1), transaction.ToID) @@ -519,13 +524,16 @@ func TestTransactionModel_GetAll(t *testing.T) { now := time.Now() // Create test transactions + testHash1 := types.HashBytea("1076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") + testHash2 := types.HashBytea("2076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") + testHash3 := types.HashBytea("3076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($1, 1, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $4, false), + ($2, 2, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $4, true), + ($3, 3, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $4, false) + `, testHash1, testHash2, testHash3, now) require.NoError(t, err) // Test GetAll without specifying cursor and limit (gets all transactions) @@ -573,13 +581,16 @@ func TestTransactionModel_BatchGetByAccountAddress(t *testing.T) { require.NoError(t, err) // Create test transactions + accTestHash1 := types.HashBytea("4076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") + accTestHash2 := types.HashBytea("5076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") + accTestHash3 := types.HashBytea("6076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($1, 1, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $4, false), + ($2, 2, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $4, true), + ($3, 3, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $4, false) + `, accTestHash1, accTestHash2, accTestHash3, now) require.NoError(t, err) // Create test transactions_accounts links (account_id is BYTEA) @@ -624,25 +635,31 @@ func TestTransactionModel_BatchGetByOperationIDs(t *testing.T) { // Create test transactions with specific ToIDs // Operations IDs must be in TOID range for each transaction: (to_id, to_id + 4096) + opTestHash1 := types.HashBytea("7076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") + opTestHash2 := types.HashBytea("8076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4876") + opTestHash3 := types.HashBytea("9076b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 4096, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 8192, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 12288, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($1, 4096, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $4, false), + ($2, 8192, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $4, true), + ($3, 12288, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $4, false) + `, opTestHash1, opTestHash2, opTestHash3, now) require.NoError(t, err) // Create test operations (IDs must be in TOID range for each transaction) - // tx1 (to_id=4096): ops 4097, 4098 - // tx2 (to_id=8192): op 8193 + // opTestHash1 (to_id=4096): ops 4097, 4098 + // opTestHash2 (to_id=8192): op 8193 + xdr1 := types.XDRBytea([]byte("xdr1")) + xdr2 := types.XDRBytea([]byte("xdr2")) + xdr3 := types.XDRBytea([]byte("xdr3")) _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO operations (id, operation_type, operation_xdr, result_code, successful, ledger_number, ledger_created_at) VALUES - (4097, 'PAYMENT', 'xdr1', 'op_success', true, 1, $1), - (8193, 'CREATE_ACCOUNT', 'xdr2', 'op_success', true, 2, $1), - (4098, 'PAYMENT', 'xdr3', 'op_success', true, 3, $1) - `, now) + (4097, 'PAYMENT', $2, 'op_success', true, 1, $1), + (8193, 'CREATE_ACCOUNT', $3, 'op_success', true, 2, $1), + (4098, 'PAYMENT', $4, 'op_success', true, 3, $1) + `, now, xdr1, xdr2, xdr3) require.NoError(t, err) // Test BatchGetByOperationIDs @@ -651,13 +668,13 @@ func TestTransactionModel_BatchGetByOperationIDs(t *testing.T) { assert.Len(t, transactions, 3) // Verify transactions are for correct operation IDs - operationIDsFound := make(map[int64]string) + operationIDsFound := make(map[int64]types.HashBytea) for _, tx := range transactions { operationIDsFound[tx.OperationID] = tx.Hash } - assert.Equal(t, "tx1", operationIDsFound[4097]) - assert.Equal(t, "tx2", operationIDsFound[8193]) - assert.Equal(t, "tx1", operationIDsFound[4098]) + assert.Equal(t, opTestHash1, operationIDsFound[4097]) + assert.Equal(t, opTestHash2, operationIDsFound[8193]) + assert.Equal(t, opTestHash1, operationIDsFound[4098]) } func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { @@ -687,13 +704,16 @@ func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { require.NoError(t, err) // Create test transactions + scTestHash1 := types.HashBytea("a176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877") + scTestHash2 := types.HashBytea("b176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877") + scTestHash3 := types.HashBytea("c176b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877") _, err = dbConnectionPool.ExecContext(ctx, ` INSERT INTO transactions (hash, to_id, envelope_xdr, fee_charged, result_code, meta_xdr, ledger_number, ledger_created_at, is_fee_bump) VALUES - ('tx1', 1, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $1, false), - ('tx2', 2, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $1, true), - ('tx3', 3, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $1, false) - `, now) + ($1, 1, 'envelope1', 100, 'TransactionResultCodeTxSuccess', 'meta1', 1, $4, false), + ($2, 2, 'envelope2', 200, 'TransactionResultCodeTxSuccess', 'meta2', 2, $4, true), + ($3, 3, 'envelope3', 300, 'TransactionResultCodeTxSuccess', 'meta3', 3, $4, false) + `, scTestHash1, scTestHash2, scTestHash3, now) require.NoError(t, err) // Create test state changes @@ -713,13 +733,13 @@ func TestTransactionModel_BatchGetByStateChangeIDs(t *testing.T) { // Verify transactions are for correct state change IDs (format: to_id-operation_id-state_change_order) // State change (to_id, operation_id, order) should return transaction with matching to_id - stateChangeIDsFound := make(map[string]string) + stateChangeIDsFound := make(map[string]types.HashBytea) for _, tx := range transactions { stateChangeIDsFound[tx.StateChangeID] = tx.Hash } - assert.Equal(t, "tx1", stateChangeIDsFound["1-1-1"]) // to_id=1 -> tx1 (to_id=1) - assert.Equal(t, "tx2", stateChangeIDsFound["2-2-1"]) // to_id=2 -> tx2 (to_id=2) - assert.Equal(t, "tx3", stateChangeIDsFound["3-3-1"]) // to_id=3 -> tx3 (to_id=3) + assert.Equal(t, scTestHash1, stateChangeIDsFound["1-1-1"]) // to_id=1 -> scTestHash1 (to_id=1) + assert.Equal(t, scTestHash2, stateChangeIDsFound["2-2-1"]) // to_id=2 -> scTestHash2 (to_id=2) + assert.Equal(t, scTestHash3, stateChangeIDsFound["3-3-1"]) // to_id=3 -> scTestHash3 (to_id=3) } func BenchmarkTransactionModel_BatchInsert(b *testing.B) { diff --git a/internal/db/migrations/2025-06-10.2-transactions.sql b/internal/db/migrations/2025-06-10.2-transactions.sql index 4ec3abf1..44ee11ae 100644 --- a/internal/db/migrations/2025-06-10.2-transactions.sql +++ b/internal/db/migrations/2025-06-10.2-transactions.sql @@ -3,7 +3,7 @@ -- Table: transactions CREATE TABLE transactions ( to_id BIGINT PRIMARY KEY, - hash TEXT NOT NULL UNIQUE, + hash BYTEA NOT NULL UNIQUE, envelope_xdr TEXT, fee_charged BIGINT NOT NULL, result_code TEXT NOT NULL, diff --git a/internal/db/migrations/2025-06-10.3-operations.sql b/internal/db/migrations/2025-06-10.3-operations.sql index a7cb9161..11fc2cbb 100644 --- a/internal/db/migrations/2025-06-10.3-operations.sql +++ b/internal/db/migrations/2025-06-10.3-operations.sql @@ -17,7 +17,7 @@ CREATE TABLE operations ( 'INVOKE_HOST_FUNCTION', 'EXTEND_FOOTPRINT_TTL', 'RESTORE_FOOTPRINT' ) ), - operation_xdr TEXT, + operation_xdr BYTEA, result_code TEXT NOT NULL, successful BOOLEAN NOT NULL, ledger_number INTEGER NOT NULL, diff --git a/internal/db/migrations/2025-06-10.4-statechanges.sql b/internal/db/migrations/2025-06-10.4-statechanges.sql index eb41b37a..22d85998 100644 --- a/internal/db/migrations/2025-06-10.4-statechanges.sql +++ b/internal/db/migrations/2025-06-10.4-statechanges.sql @@ -23,7 +23,7 @@ CREATE TABLE state_changes ( ledger_number INTEGER NOT NULL, account_id BYTEA NOT NULL, operation_id BIGINT NOT NULL, - token_id TEXT, + token_id BYTEA, amount TEXT, signer_account_id BYTEA, spender_account_id BYTEA, diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 4adad122..fe21ed04 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -253,7 +253,7 @@ func (i *Indexer) processTransaction(ctx context.Context, tx ingest.LedgerTransa contractChange := types.ContractChange{ AccountID: string(stateChange.AccountID), OperationID: stateChange.OperationID, - ContractID: stateChange.TokenID.String, + ContractID: stateChange.TokenID.String(), LedgerNumber: tx.Ledger.LedgerSequence(), ContractType: stateChange.ContractType, } diff --git a/internal/indexer/indexer_buffer.go b/internal/indexer/indexer_buffer.go index 3f5714c3..0fdc525a 100644 --- a/internal/indexer/indexer_buffer.go +++ b/internal/indexer/indexer_buffer.go @@ -114,7 +114,7 @@ func (b *IndexerBuffer) PushTransaction(participant string, transaction types.Tr // // Caller must hold write lock. func (b *IndexerBuffer) pushTransactionUnsafe(participant string, transaction *types.Transaction) { - txHash := transaction.Hash + txHash := transaction.Hash.String() if _, exists := b.txByHash[txHash]; !exists { b.txByHash[txHash] = transaction } @@ -171,7 +171,7 @@ func (b *IndexerBuffer) GetTransactionsParticipants() map[int64]set.Set[string] b.mu.RLock() defer b.mu.RUnlock() - return b.participantsByToID + return maps.Clone(b.participantsByToID) } // PushTrustlineChange adds a trustline change to the buffer and tracks unique assets. @@ -351,7 +351,7 @@ func (b *IndexerBuffer) GetOperationsParticipants() map[int64]set.Set[string] { b.mu.RLock() defer b.mu.RUnlock() - return b.participantsByOpID + return maps.Clone(b.participantsByOpID) } // pushOperationUnsafe is the internal implementation for operation storage. diff --git a/internal/indexer/indexer_buffer_test.go b/internal/indexer/indexer_buffer_test.go index 93818cff..c868b533 100644 --- a/internal/indexer/indexer_buffer_test.go +++ b/internal/indexer/indexer_buffer_test.go @@ -27,8 +27,8 @@ func TestIndexerBuffer_PushTransaction(t *testing.T) { t.Run("🟢 sequential pushes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} indexerBuffer.PushTransaction("alice", tx1) indexerBuffer.PushTransaction("alice", tx2) @@ -50,8 +50,8 @@ func TestIndexerBuffer_PushTransaction(t *testing.T) { t.Run("🟢 concurrent pushes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} wg := sync.WaitGroup{} wg.Add(4) @@ -87,8 +87,8 @@ func TestIndexerBuffer_PushOperation(t *testing.T) { t.Run("🟢 sequential pushes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} @@ -111,8 +111,8 @@ func TestIndexerBuffer_PushOperation(t *testing.T) { t.Run("🟢 concurrent pushes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} @@ -147,7 +147,7 @@ func TestIndexerBuffer_PushStateChange(t *testing.T) { t.Run("🟢 sequential pushes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "test_tx_hash", ToID: 1} + tx := types.Transaction{Hash: "c76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48763", ToID: 1} op := types.Operation{ID: 1} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1} @@ -165,7 +165,7 @@ func TestIndexerBuffer_PushStateChange(t *testing.T) { t.Run("🟢 concurrent pushes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "test_tx_hash", ToID: 1} + tx := types.Transaction{Hash: "c76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48763", ToID: 1} op := types.Operation{ID: 1} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1} @@ -196,8 +196,8 @@ func TestIndexerBuffer_PushStateChange(t *testing.T) { t.Run("🟢 with operations and transactions", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} op1 := types.Operation{ID: 3} op2 := types.Operation{ID: 4} op3 := types.Operation{ID: 5} @@ -239,8 +239,8 @@ func TestIndexerBuffer_GetNumberOfTransactions(t *testing.T) { assert.Equal(t, 0, indexerBuffer.GetNumberOfTransactions()) - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} indexerBuffer.PushTransaction("alice", tx1) assert.Equal(t, 1, indexerBuffer.GetNumberOfTransactions()) @@ -258,8 +258,8 @@ func TestIndexerBuffer_GetAllTransactions(t *testing.T) { t.Run("🟢 returns all unique transactions", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1, LedgerNumber: 100} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2, LedgerNumber: 101} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1, LedgerNumber: 100} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2, LedgerNumber: 101} indexerBuffer.PushTransaction("alice", tx1) indexerBuffer.PushTransaction("bob", tx2) @@ -275,8 +275,8 @@ func TestIndexerBuffer_GetAllTransactionsParticipants(t *testing.T) { t.Run("🟢 returns correct participants mapping", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} indexerBuffer.PushTransaction("alice", tx1) indexerBuffer.PushTransaction("bob", tx1) @@ -292,7 +292,7 @@ func TestIndexerBuffer_GetAllOperations(t *testing.T) { t.Run("🟢 returns all unique operations", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} @@ -310,7 +310,7 @@ func TestIndexerBuffer_GetAllOperationsParticipants(t *testing.T) { t.Run("🟢 returns correct participants mapping", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} @@ -328,7 +328,7 @@ func TestIndexerBuffer_GetAllStateChanges(t *testing.T) { t.Run("🟢 returns all state changes in order", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "test_tx_hash", ToID: 1} + tx := types.Transaction{Hash: "c76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48763", ToID: 1} op := types.Operation{ID: 1} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1, AccountID: "alice"} @@ -354,8 +354,8 @@ func TestIndexerBuffer_GetAllParticipants(t *testing.T) { t.Run("🟢 collects participants from transactions", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} indexerBuffer.PushTransaction("alice", tx1) indexerBuffer.PushTransaction("bob", tx2) @@ -368,7 +368,7 @@ func TestIndexerBuffer_GetAllParticipants(t *testing.T) { t.Run("🟢 collects participants from operations", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} @@ -383,7 +383,7 @@ func TestIndexerBuffer_GetAllParticipants(t *testing.T) { t.Run("🟢 collects participants from state changes", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op := types.Operation{ID: 1} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1, AccountID: "alice", OperationID: 1} @@ -401,7 +401,7 @@ func TestIndexerBuffer_GetAllParticipants(t *testing.T) { t.Run("🟢 collects unique participants from all sources", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op := types.Operation{ID: 1} sc := types.StateChange{ToID: 1, StateChangeOrder: 1, AccountID: "dave", OperationID: 1} @@ -418,7 +418,7 @@ func TestIndexerBuffer_GetAllParticipants(t *testing.T) { t.Run("🟢 ignores empty participants", func(t *testing.T) { indexerBuffer := NewIndexerBuffer() - tx := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} indexerBuffer.PushTransaction("", tx) // empty participant indexerBuffer.PushTransaction("alice", tx) @@ -441,8 +441,8 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} buffer1.PushTransaction("alice", tx1) buffer2.PushTransaction("bob", tx2) @@ -464,7 +464,7 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} @@ -488,7 +488,7 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx := types.Transaction{Hash: "test_tx_hash", ToID: 1} + tx := types.Transaction{Hash: "c76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48763", ToID: 1} op := types.Operation{ID: 1} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1, AccountID: "alice"} @@ -510,8 +510,8 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} op1 := types.Operation{ID: 1} // Buffer1 has tx1 with alice @@ -543,7 +543,7 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} op1 := types.Operation{ID: 1} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1, AccountID: "alice"} @@ -562,7 +562,7 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} buffer1.PushTransaction("alice", tx1) buffer1.Merge(buffer2) @@ -575,9 +575,9 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer2 := NewIndexerBuffer() buffer3 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} - tx3 := types.Transaction{Hash: "tx_hash_3", ToID: 3} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} + tx3 := types.Transaction{Hash: "b76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48762", ToID: 3} buffer1.PushTransaction("alice", tx1) buffer2.PushTransaction("bob", tx2) @@ -606,8 +606,8 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} op1 := types.Operation{ID: 1} op2 := types.Operation{ID: 2} sc1 := types.StateChange{ToID: 1, StateChangeOrder: 1, AccountID: "alice", OperationID: 1} @@ -653,8 +653,8 @@ func TestIndexerBuffer_Merge(t *testing.T) { buffer1 := NewIndexerBuffer() buffer2 := NewIndexerBuffer() - tx1 := types.Transaction{Hash: "tx_hash_1", ToID: 1} - tx2 := types.Transaction{Hash: "tx_hash_2", ToID: 2} + tx1 := types.Transaction{Hash: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", ToID: 1} + tx2 := types.Transaction{Hash: "a76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48761", ToID: 2} buffer1.PushTransaction("alice", tx1) buffer1.PushTransaction("bob", tx1) diff --git a/internal/indexer/processors/contracts/test_utils.go b/internal/indexer/processors/contracts/test_utils.go index d6ca9c03..3b26284f 100644 --- a/internal/indexer/processors/contracts/test_utils.go +++ b/internal/indexer/processors/contracts/test_utils.go @@ -628,7 +628,7 @@ func assertContractEvent(t *testing.T, change types.StateChange, reason types.St require.Equal(t, expectedAccount, change.AccountID.String()) if expectedContractID != "" { require.NotNil(t, change.TokenID) - require.Equal(t, expectedContractID, change.TokenID.String) + require.Equal(t, expectedContractID, change.TokenID.String()) } require.Equal(t, reason, *change.StateChangeReason) } diff --git a/internal/indexer/processors/effects_test.go b/internal/indexer/processors/effects_test.go index 4043faff..c3e00a95 100644 --- a/internal/indexer/processors/effects_test.go +++ b/internal/indexer/processors/effects_test.go @@ -372,7 +372,7 @@ func TestEffects_ProcessTransaction(t *testing.T) { asset := xdr.MustNewCreditAsset("TEST", "GBNOOJYISY7Y5IKJFDOGDTVQMPO6DZ46SCS64O2IB4NSCAMXGCKOLORN") assetContractID, err := asset.ContractID(networkPassphrase) require.NoError(t, err) - assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String) + assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String()) }) t.Run("ChangeTrust - trustline updated", func(t *testing.T) { envelopeXDR := "AAAAAHHbEhVipyZ2k4byyCZkS1Bdvpj7faBChuYo8S/Rt89UAAAAZAAQuJIAAAAHAAAAAQAAAAAAAAAAAAAAAF4XVskAAAAAAAAAAQAAAAAAAAAGAAAAAlRFU1RBU1NFVAAAAAAAAAA7JUkkD+tgCi2xTVyEcs4WZXOA0l7w2orZg/bghXOgkAAAAAA7msoAAAAAAAAAAAHRt89UAAAAQOCi2ylqRvvRzZaCFjGkLYFk7DCjJA5uZ1nXo8FaPCRl2LZczoMbc46sZIlHh0ENzk7fKjFnRPMo8XAirrrf2go=" @@ -409,7 +409,7 @@ func TestEffects_ProcessTransaction(t *testing.T) { asset := xdr.MustNewCreditAsset("TESTASSET", "GA5SKSJEB7VWACRNWFGVZBDSZYLGK44A2JPPBWUK3GB7NYEFOOQJAC2B") assetContractID, err := asset.ContractID(networkPassphrase) require.NoError(t, err) - assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String) + assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String()) assert.Equal(t, "1000000000", changes[0].TrustlineLimitOld.String) assert.Equal(t, "100.0000000", changes[0].TrustlineLimitNew.String) }) @@ -448,6 +448,6 @@ func TestEffects_ProcessTransaction(t *testing.T) { asset := xdr.MustNewCreditAsset("OCIToken", "GBE4L76HUCHCQ2B7IIWBXRAJDBDPIY6MGWX7VZHUZD2N5RO7XI4J6GTJ") assetContractID, err := asset.ContractID(networkPassphrase) require.NoError(t, err) - assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String) + assert.Equal(t, strkey.MustEncode(strkey.VersionByteContract, assetContractID[:]), changes[0].TokenID.String()) }) } diff --git a/internal/indexer/processors/processors_test_utils.go b/internal/indexer/processors/processors_test_utils.go index a7e7c014..d2ebbd32 100644 --- a/internal/indexer/processors/processors_test_utils.go +++ b/internal/indexer/processors/processors_test_utils.go @@ -811,7 +811,7 @@ func assertStateChangeBase(t *testing.T, change types.StateChange, category type require.Equal(t, utils.SQLNullString(expectedAmount), change.Amount) } if expectedToken != "" { - require.Equal(t, utils.SQLNullString(expectedToken), change.TokenID) + require.Equal(t, utils.NullAddressBytea(expectedToken), change.TokenID) } } diff --git a/internal/indexer/processors/state_change_builder.go b/internal/indexer/processors/state_change_builder.go index a6b6e26d..8fc77cc7 100644 --- a/internal/indexer/processors/state_change_builder.go +++ b/internal/indexer/processors/state_change_builder.go @@ -124,7 +124,7 @@ func (b *StateChangeBuilder) WithAmount(amount string) *StateChangeBuilder { // WithToken sets the token ID using the contract address func (b *StateChangeBuilder) WithToken(contractAddress string) *StateChangeBuilder { - b.base.TokenID = utils.SQLNullString(contractAddress) + b.base.TokenID = utils.NullAddressBytea(contractAddress) return b } @@ -190,7 +190,7 @@ func (b *StateChangeBuilder) generateSortKey() string { b.base.StateChangeCategory, reason, b.base.AccountID, - b.base.TokenID.String, + b.base.TokenID.String(), b.base.Amount.String, b.base.SignerAccountID.String(), b.base.SpenderAccountID.String(), diff --git a/internal/indexer/processors/utils.go b/internal/indexer/processors/utils.go index 4176d098..c4cfb614 100644 --- a/internal/indexer/processors/utils.go +++ b/internal/indexer/processors/utils.go @@ -309,7 +309,7 @@ func ConvertTransaction(transaction *ingest.LedgerTransaction, skipTxMeta bool, return &types.Transaction{ ToID: transactionID, - Hash: transaction.Hash.HexString(), + Hash: types.HashBytea(transaction.Hash.HexString()), LedgerCreatedAt: transaction.Ledger.ClosedAt(), EnvelopeXDR: envelopeXDR, FeeCharged: feeCharged, @@ -328,7 +328,7 @@ func ConvertOperation( opIndex uint32, opResults []xdr.OperationResult, ) (*types.Operation, error) { - xdrOpStr, err := xdr.MarshalBase64(op) + xdrBytes, err := op.MarshalBinary() if err != nil { return nil, fmt.Errorf("marshalling operation %d: %w", opID, err) } @@ -350,7 +350,7 @@ func ConvertOperation( return &types.Operation{ ID: opID, OperationType: types.OperationTypeFromXDR(op.Body.Type), - OperationXDR: xdrOpStr, + OperationXDR: types.XDRBytea(xdrBytes), ResultCode: resultCode, Successful: successful, LedgerCreatedAt: transaction.Ledger.ClosedAt(), diff --git a/internal/indexer/processors/utils_test.go b/internal/indexer/processors/utils_test.go index 0f68c129..483e1e97 100644 --- a/internal/indexer/processors/utils_test.go +++ b/internal/indexer/processors/utils_test.go @@ -1,6 +1,7 @@ package processors import ( + "encoding/base64" "testing" "time" @@ -101,10 +102,14 @@ func Test_ConvertOperation(t *testing.T) { gotDataOp, err := ConvertOperation(&ingestTx, &op, opID, opIndex, opResults) require.NoError(t, err) + // Decode expected base64 XDR to raw bytes for comparison + expectedXDRBytes, err := base64.StdEncoding.DecodeString(opXDRStr) + require.NoError(t, err) + wantDataOp := &types.Operation{ ID: opID, OperationType: types.OperationTypeFromXDR(op.Body.Type), - OperationXDR: opXDRStr, + OperationXDR: types.XDRBytea(expectedXDRBytes), ResultCode: OpSuccess, Successful: true, LedgerCreatedAt: time.Date(2025, time.June, 19, 0, 3, 16, 0, time.UTC), diff --git a/internal/indexer/types/types.go b/internal/indexer/types/types.go index 24c7af75..091370b9 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -34,6 +34,8 @@ package types import ( "database/sql" "database/sql/driver" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "time" @@ -123,6 +125,83 @@ func (n NullAddressBytea) String() string { return string(n.AddressBytea) } +// HashBytea represents a transaction hash stored as BYTEA in the database. +// Storage format: 32 bytes (raw SHA-256 hash) +// Go representation: hex string (64 characters) +type HashBytea string + +// Scan implements sql.Scanner - converts BYTEA (32 bytes) to hex string +func (h *HashBytea) Scan(value any) error { + if value == nil { + *h = "" + return nil + } + bytes, ok := value.([]byte) + if !ok { + return fmt.Errorf("expected []byte, got %T", value) + } + if len(bytes) != 32 { + return fmt.Errorf("expected 32 bytes, got %d", len(bytes)) + } + *h = HashBytea(hex.EncodeToString(bytes)) + return nil +} + +// Value implements driver.Valuer - converts hex string to 32-byte []byte +func (h HashBytea) Value() (driver.Value, error) { + if h == "" { + return nil, nil + } + bytes, err := hex.DecodeString(string(h)) + if err != nil { + return nil, fmt.Errorf("decoding hex hash %s: %w", h, err) + } + if len(bytes) != 32 { + return nil, fmt.Errorf("invalid hash length: expected 32 bytes, got %d", len(bytes)) + } + return bytes, nil +} + +// String returns the hash as a hex string. +func (h HashBytea) String() string { + return string(h) +} + +// XDRBytea represents XDR data stored as BYTEA in the database. +// Storage format: raw XDR bytes (variable length) +// Go representation: raw bytes internally, base64 string via String() +type XDRBytea []byte + +// Scan implements sql.Scanner - reads raw bytes from BYTEA column +func (x *XDRBytea) Scan(value any) error { + if value == nil { + *x = nil + return nil + } + bytes, ok := value.([]byte) + if !ok { + return fmt.Errorf("expected []byte, got %T", value) + } + *x = make([]byte, len(bytes)) + copy(*x, bytes) + return nil +} + +// Value implements driver.Valuer - returns raw bytes for BYTEA storage +func (x XDRBytea) Value() (driver.Value, error) { + if len(x) == 0 { + return nil, nil + } + buf := make([]byte, len(x)) + copy(buf, x) + return buf, nil +} + +// String returns the XDR as a base64 string. +func (x XDRBytea) String() string { + return base64.StdEncoding.EncodeToString(x) +} + type ContractType string const ( @@ -215,7 +294,7 @@ type AccountWithOperationID struct { } type Transaction struct { - Hash string `json:"hash,omitempty" db:"hash"` + Hash HashBytea `json:"hash,omitempty" db:"hash"` ToID int64 `json:"toId,omitempty" db:"to_id"` EnvelopeXDR *string `json:"envelopeXdr,omitempty" db:"envelope_xdr"` FeeCharged int64 `json:"feeCharged,omitempty" db:"fee_charged"` @@ -329,7 +408,7 @@ type Operation struct { // The parent transaction's to_id can be derived: ID &^ 0xFFF ID int64 `json:"id,omitempty" db:"id"` OperationType OperationType `json:"operationType,omitempty" db:"operation_type"` - OperationXDR string `json:"operationXdr,omitempty" db:"operation_xdr"` + OperationXDR XDRBytea `json:"operationXdr,omitempty" db:"operation_xdr"` ResultCode string `json:"resultCode,omitempty" db:"result_code"` Successful bool `json:"successful,omitempty" db:"successful"` LedgerNumber uint32 `json:"ledgerNumber,omitempty" db:"ledger_number"` @@ -485,9 +564,9 @@ type StateChange struct { LedgerCreatedAt time.Time `json:"ledgerCreatedAt,omitempty" db:"ledger_created_at"` LedgerNumber uint32 `json:"ledgerNumber,omitempty" db:"ledger_number"` - // Nullable string fields: - TokenID sql.NullString `json:"tokenId,omitempty" db:"token_id"` - Amount sql.NullString `json:"amount,omitempty" db:"amount"` + // Nullable address fields (stored as BYTEA in database): + TokenID NullAddressBytea `json:"tokenId,omitempty" db:"token_id"` + Amount sql.NullString `json:"amount,omitempty" db:"amount"` // Nullable address fields (stored as BYTEA in database): SignerAccountID NullAddressBytea `json:"signerAccountId,omitempty" db:"signer_account_id"` diff --git a/internal/indexer/types/types_test.go b/internal/indexer/types/types_test.go index 3c507656..c8422f89 100644 --- a/internal/indexer/types/types_test.go +++ b/internal/indexer/types/types_test.go @@ -2,6 +2,7 @@ package types import ( "database/sql/driver" + "encoding/hex" "testing" "github.com/stellar/go-stellar-sdk/keypair" @@ -241,3 +242,140 @@ func TestNullableJSONB_Value(t *testing.T) { }) } } + +func TestHashBytea_Scan(t *testing.T) { + // Valid 32-byte hash + validHex := "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760" + validBytes, err := hex.DecodeString(validHex) + require.NoError(t, err) + + testCases := []struct { + name string + input any + want HashBytea + wantErrContains string + }{ + { + name: "🟢nil value", + input: nil, + want: "", + }, + { + name: "🟢valid 32-byte hash", + input: validBytes, + want: HashBytea(validHex), + }, + { + name: "🔴wrong type", + input: 12345, + wantErrContains: "expected []byte", + }, + { + name: "🔴wrong length", + input: []byte{1, 2, 3}, + wantErrContains: "expected 32 bytes", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var h HashBytea + err := h.Scan(tc.input) + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.want, h) + } + }) + } +} + +func TestHashBytea_Value(t *testing.T) { + // Valid 32-byte hash + validHex := "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760" + expectedBytes, err := hex.DecodeString(validHex) + require.NoError(t, err) + + testCases := []struct { + name string + input HashBytea + want driver.Value + wantErrContains string + }{ + { + name: "🟢empty string", + input: "", + want: nil, + }, + { + name: "🟢valid hex hash", + input: HashBytea(validHex), + want: expectedBytes, + }, + { + name: "🔴invalid hex", + input: "not-a-valid-hex", + wantErrContains: "decoding hex hash", + }, + { + name: "🔴wrong length (short)", + input: "abcd", + wantErrContains: "invalid hash length", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.input.Value() + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestHashBytea_Roundtrip(t *testing.T) { + // Test that Value -> Scan produces the original hash + original := HashBytea("e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760") + + // Convert to bytes + bytes, err := original.Value() + require.NoError(t, err) + + // Convert back to hash + var restored HashBytea + err = restored.Scan(bytes) + require.NoError(t, err) + + assert.Equal(t, original, restored) +} + +func TestHashBytea_String(t *testing.T) { + testCases := []struct { + name string + input HashBytea + want string + }{ + { + name: "🟢empty string", + input: "", + want: "", + }, + { + name: "🟢valid hex hash", + input: HashBytea("e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760"), + want: "e76b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa48760", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := tc.input.String() + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 88c82a13..836693bd 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -164,7 +164,7 @@ type ComplexityRoot struct { LedgerCreatedAt func(childComplexity int) int LedgerNumber func(childComplexity int) int OperationType func(childComplexity int) int - OperationXDR func(childComplexity int) int + OperationXdr func(childComplexity int) int ResultCode func(childComplexity int) int StateChanges func(childComplexity int, first *int32, after *string, last *int32, before *string) int Successful func(childComplexity int) int @@ -395,6 +395,8 @@ type MutationResolver interface { CreateFeeBumpTransaction(ctx context.Context, input CreateFeeBumpTransactionInput) (*CreateFeeBumpTransactionPayload, error) } type OperationResolver interface { + OperationXdr(ctx context.Context, obj *types.Operation) (string, error) + Transaction(ctx context.Context, obj *types.Operation) (*types.Transaction, error) Accounts(ctx context.Context, obj *types.Operation) ([]*types.Account, error) StateChanges(ctx context.Context, obj *types.Operation, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) @@ -453,6 +455,8 @@ type StandardBalanceChangeResolver interface { Amount(ctx context.Context, obj *types.StandardBalanceStateChangeModel) (string, error) } type TransactionResolver interface { + Hash(ctx context.Context, obj *types.Transaction) (string, error) + Operations(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*OperationConnection, error) Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) StateChanges(ctx context.Context, obj *types.Transaction, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) @@ -1007,11 +1011,11 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Operation.OperationType(childComplexity), true case "Operation.operationXdr": - if e.complexity.Operation.OperationXDR == nil { + if e.complexity.Operation.OperationXdr == nil { break } - return e.complexity.Operation.OperationXDR(childComplexity), true + return e.complexity.Operation.OperationXdr(childComplexity), true case "Operation.resultCode": if e.complexity.Operation.ResultCode == nil { @@ -2314,7 +2318,7 @@ type CreateFeeBumpTransactionPayload { type Operation{ id: Int64! operationType: OperationType! - operationXdr: String! + operationXdr: String! @goField(forceResolver: true) resultCode: String! successful: Boolean! ledgerNumber: UInt32! @@ -2551,7 +2555,7 @@ type BalanceAuthorizationChange implements BaseStateChange{ {Name: "../schema/transaction.graphqls", Input: `# GraphQL Transaction type - represents a blockchain transaction # gqlgen generates Go structs from this schema definition type Transaction{ - hash: String! + hash: String! @goField(forceResolver: true) envelopeXdr: String feeCharged: Int64! resultCode: String! @@ -6820,7 +6824,7 @@ func (ec *executionContext) _Operation_operationXdr(ctx context.Context, field g }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return obj.OperationXDR, nil + return ec.resolvers.Operation().OperationXdr(rctx, obj) }) if err != nil { ec.Error(ctx, err) @@ -6841,8 +6845,8 @@ func (ec *executionContext) fieldContext_Operation_operationXdr(_ context.Contex fc = &graphql.FieldContext{ Object: "Operation", Field: field, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type String does not have child fields") }, @@ -11278,7 +11282,7 @@ func (ec *executionContext) _Transaction_hash(ctx context.Context, field graphql }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return obj.Hash, nil + return ec.resolvers.Transaction().Hash(rctx, obj) }) if err != nil { ec.Error(ctx, err) @@ -11299,8 +11303,8 @@ func (ec *executionContext) fieldContext_Transaction_hash(_ context.Context, fie fc = &graphql.FieldContext{ Object: "Transaction", Field: field, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type String does not have child fields") }, @@ -17010,10 +17014,41 @@ func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet atomic.AddUint32(&out.Invalids, 1) } case "operationXdr": - out.Values[i] = ec._Operation_operationXdr(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Operation_operationXdr(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "resultCode": out.Values[i] = ec._Operation_resultCode(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -19054,10 +19089,41 @@ func (ec *executionContext) _Transaction(ctx context.Context, sel ast.SelectionS case "__typename": out.Values[i] = graphql.MarshalString("Transaction") case "hash": - out.Values[i] = ec._Transaction_hash(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Transaction_hash(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "envelopeXdr": out.Values[i] = ec._Transaction_envelopeXdr(ctx, field, obj) case "feeCharged": diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index 42cce7eb..1f91217f 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -9,8 +9,11 @@ import ( "fmt" "strings" + "github.com/vektah/gqlparser/v2/gqlerror" + "github.com/stellar/wallet-backend/internal/indexer/types" graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" + "github.com/stellar/wallet-backend/internal/utils" ) // Address is the resolver for the address field. @@ -101,6 +104,15 @@ func (r *accountResolver) StateChanges(ctx context.Context, obj *types.Account, var reason *string if filter != nil { if filter.TransactionHash != nil { + if !utils.IsValidTransactionHash(*filter.TransactionHash) { + return nil, &gqlerror.Error{ + Message: ErrMsgInvalidTransactionHash, + Extensions: map[string]interface{}{ + "code": "INVALID_TRANSACTION_HASH", + "hash": *filter.TransactionHash, + }, + } + } txHash = filter.TransactionHash } if filter.OperationID != nil { diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 9107b53a..609786c5 100644 --- a/internal/serve/graphql/resolvers/account_resolvers_test.go +++ b/internal/serve/graphql/resolvers/account_resolvers_test.go @@ -1,6 +1,8 @@ package resolvers import ( + "encoding/base64" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -15,6 +17,11 @@ import ( graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" ) +// testOpXDRAcc returns the expected base64-encoded XDR for test operation N +func testOpXDRAcc(n int) string { + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("opxdr%d", n))) +} + func TestAccountResolver_Transactions(t *testing.T) { parentAccount := &types.Account{StellarAddress: types.AddressBytea(sharedTestAccountAddress)} @@ -39,10 +46,10 @@ func TestAccountResolver_Transactions(t *testing.T) { require.NoError(t, err) require.Len(t, transactions.Edges, 4) - assert.Equal(t, "tx1", transactions.Edges[0].Node.Hash) - assert.Equal(t, "tx2", transactions.Edges[1].Node.Hash) - assert.Equal(t, "tx3", transactions.Edges[2].Node.Hash) - assert.Equal(t, "tx4", transactions.Edges[3].Node.Hash) + assert.Equal(t, testTxHash1, transactions.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash2, transactions.Edges[1].Node.Hash.String()) + assert.Equal(t, testTxHash3, transactions.Edges[2].Node.Hash.String()) + assert.Equal(t, testTxHash4, transactions.Edges[3].Node.Hash.String()) mockMetricsService.AssertExpectations(t) }) @@ -52,8 +59,8 @@ func TestAccountResolver_Transactions(t *testing.T) { txs, err := resolver.Transactions(ctx, parentAccount, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 2) - assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) - assert.Equal(t, "tx2", txs.Edges[1].Node.Hash) + assert.Equal(t, testTxHash1, txs.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash2, txs.Edges[1].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.False(t, txs.PageInfo.HasPreviousPage) @@ -63,8 +70,8 @@ func TestAccountResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, parentAccount, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 2) - assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) - assert.Equal(t, "tx4", txs.Edges[1].Node.Hash) + assert.Equal(t, testTxHash3, txs.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash4, txs.Edges[1].Node.Hash.String()) assert.False(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) mockMetricsService.AssertExpectations(t) @@ -76,8 +83,8 @@ func TestAccountResolver_Transactions(t *testing.T) { txs, err := resolver.Transactions(ctx, parentAccount, nil, nil, &last, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 2) - assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) - assert.Equal(t, "tx4", txs.Edges[1].Node.Hash) + assert.Equal(t, testTxHash3, txs.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash4, txs.Edges[1].Node.Hash.String()) assert.False(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) @@ -88,7 +95,7 @@ func TestAccountResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, parentAccount, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, txs.Edges, 1) - assert.Equal(t, "tx2", txs.Edges[0].Node.Hash) + assert.Equal(t, testTxHash2, txs.Edges[0].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) @@ -98,7 +105,7 @@ func TestAccountResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, parentAccount, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, txs.Edges, 1) - assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) + assert.Equal(t, testTxHash1, txs.Edges[0].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.False(t, txs.PageInfo.HasPreviousPage) mockMetricsService.AssertExpectations(t) @@ -166,10 +173,10 @@ func TestAccountResolver_Operations(t *testing.T) { require.NoError(t, err) require.Len(t, operations.Edges, 8) - assert.Equal(t, "opxdr1", operations.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", operations.Edges[1].Node.OperationXDR) - assert.Equal(t, "opxdr3", operations.Edges[2].Node.OperationXDR) - assert.Equal(t, "opxdr4", operations.Edges[3].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(1), operations.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(2), operations.Edges[1].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(3), operations.Edges[2].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(4), operations.Edges[3].Node.OperationXDR.String()) }) t.Run("get operations with first/after limit and cursor", func(t *testing.T) { @@ -178,8 +185,8 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err := resolver.Operations(ctx, parentAccount, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(1), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(2), ops.Edges[1].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) @@ -189,8 +196,8 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, "opxdr3", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr4", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(3), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(4), ops.Edges[1].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -200,10 +207,10 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 4) - assert.Equal(t, "opxdr5", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr6", ops.Edges[1].Node.OperationXDR) - assert.Equal(t, "opxdr7", ops.Edges[2].Node.OperationXDR) - assert.Equal(t, "opxdr8", ops.Edges[3].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(5), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(6), ops.Edges[1].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(7), ops.Edges[2].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(8), ops.Edges[3].Node.OperationXDR.String()) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) }) @@ -214,8 +221,8 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err := resolver.Operations(ctx, parentAccount, nil, nil, &last, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, "opxdr7", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr8", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(7), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(8), ops.Edges[1].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasPreviousPage) assert.False(t, ops.PageInfo.HasNextPage) @@ -225,8 +232,8 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, "opxdr5", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr6", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(5), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(6), ops.Edges[1].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -236,10 +243,10 @@ func TestAccountResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentAccount, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 4) - assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) - assert.Equal(t, "opxdr3", ops.Edges[2].Node.OperationXDR) - assert.Equal(t, "opxdr4", ops.Edges[3].Node.OperationXDR) + assert.Equal(t, testOpXDRAcc(1), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(2), ops.Edges[1].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(3), ops.Edges[2].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRAcc(4), ops.Edges[3].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) }) @@ -518,7 +525,7 @@ func TestAccountResolver_StateChanges_WithFilters(t *testing.T) { t.Run("filter by transaction hash only", func(t *testing.T) { ctx := getTestCtx("state_changes", []string{""}) - txHash := "tx1" + txHash := testTxHash1 filter := &graphql1.AccountStateChangeFilterInput{ TransactionHash: &txHash, } @@ -591,7 +598,7 @@ func TestAccountResolver_StateChanges_WithFilters(t *testing.T) { t.Run("filter by both transaction hash and operation ID", func(t *testing.T) { ctx := getTestCtx("state_changes", []string{""}) - txHash := "tx2" + txHash := testTxHash2 opID := toid.New(1000, 2, 1).ToInt64() txToID := opID &^ 0xFFF // Derive transaction to_id from operation_id filter := &graphql1.AccountStateChangeFilterInput{ @@ -618,7 +625,8 @@ func TestAccountResolver_StateChanges_WithFilters(t *testing.T) { t.Run("filter with no matching results", func(t *testing.T) { ctx := getTestCtx("state_changes", []string{""}) - txHash := "non-existent-tx" + // Use a valid 64-char hex hash that doesn't exist in the test DB + txHash := "0000000000000000000000000000000000000000000000000000000000000000" filter := &graphql1.AccountStateChangeFilterInput{ TransactionHash: &txHash, } @@ -632,7 +640,7 @@ func TestAccountResolver_StateChanges_WithFilters(t *testing.T) { t.Run("filter with pagination", func(t *testing.T) { ctx := getTestCtx("state_changes", []string{""}) - txHash := "tx1" + txHash := testTxHash1 filter := &graphql1.AccountStateChangeFilterInput{ TransactionHash: &txHash, } @@ -766,7 +774,7 @@ func TestAccountResolver_StateChanges_WithCategoryReasonFilters(t *testing.T) { t.Run("filter with all filters - txHash, operationID, category, reason", func(t *testing.T) { ctx := getTestCtx("state_changes", []string{""}) - txHash := "tx1" + txHash := testTxHash1 opID := toid.New(1000, 1, 1).ToInt64() txToID := opID &^ 0xFFF // Derive transaction to_id from operation_id category := "BALANCE" diff --git a/internal/serve/graphql/resolvers/errors.go b/internal/serve/graphql/resolvers/errors.go index 3c733740..e96604aa 100644 --- a/internal/serve/graphql/resolvers/errors.go +++ b/internal/serve/graphql/resolvers/errors.go @@ -22,6 +22,9 @@ const ( // BalancesByAccountAddress errors (single account) ErrMsgSingleInvalidAddress = "invalid address format: must be a valid Stellar account (G...) or contract (C...) address" + // TransactionByHash / StateChanges hash filter errors + ErrMsgInvalidTransactionHash = "invalid transaction hash format: must be a 64-character hex string" + // BalancesByAccountAddresses errors (multiple accounts) ErrMsgEmptyAddresses = "addresses array cannot be empty" ErrMsgTooManyAddresses = "maximum %d addresses allowed per query, got %d" diff --git a/internal/serve/graphql/resolvers/operation.resolvers.go b/internal/serve/graphql/resolvers/operation.resolvers.go index ca23c400..4097f59c 100644 --- a/internal/serve/graphql/resolvers/operation.resolvers.go +++ b/internal/serve/graphql/resolvers/operation.resolvers.go @@ -15,6 +15,12 @@ import ( "github.com/stellar/wallet-backend/internal/serve/middleware" ) +// OperationXdr is the resolver for the operationXdr field. +// Returns the operation XDR as a base64-encoded string. +func (r *operationResolver) OperationXdr(ctx context.Context, obj *types.Operation) (string, error) { + return obj.OperationXDR.String(), nil +} + // Transaction is the resolver for the transaction field. // This is a field resolver - it resolves the "transaction" field on an Operation object // gqlgen calls this when a GraphQL query requests the transaction field on an Operation diff --git a/internal/serve/graphql/resolvers/operation_resolvers_test.go b/internal/serve/graphql/resolvers/operation_resolvers_test.go index 241f02b7..fcb384c4 100644 --- a/internal/serve/graphql/resolvers/operation_resolvers_test.go +++ b/internal/serve/graphql/resolvers/operation_resolvers_test.go @@ -42,7 +42,7 @@ func TestOperationResolver_Transaction(t *testing.T) { require.NoError(t, err) require.NotNil(t, transaction) - assert.Equal(t, "tx1", transaction.Hash) + assert.Equal(t, testTxHash1, transaction.Hash.String()) }) t.Run("nil operation panics", func(t *testing.T) { diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index 3677303a..80550d3f 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -26,6 +26,15 @@ import ( // This is a root query resolver - it handles the "transactionByHash" query. // gqlgen calls this function when a GraphQL query requests "transactionByHash" func (r *queryResolver) TransactionByHash(ctx context.Context, hash string) (*types.Transaction, error) { + if !utils.IsValidTransactionHash(hash) { + return nil, &gqlerror.Error{ + Message: ErrMsgInvalidTransactionHash, + Extensions: map[string]interface{}{ + "code": "INVALID_TRANSACTION_HASH", + "hash": hash, + }, + } + } dbColumns := GetDBColumnsForFields(ctx, types.Transaction{}) return r.models.Transactions.GetByHash(ctx, hash, strings.Join(dbColumns, ", ")) } diff --git a/internal/serve/graphql/resolvers/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index 2696b88b..0cdd8e73 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -1,17 +1,25 @@ package resolvers import ( + "encoding/base64" + "fmt" "testing" "github.com/stellar/go-stellar-sdk/toid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/vektah/gqlparser/v2/gqlerror" "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/metrics" ) +// testOpXDR returns the expected base64-encoded XDR for test operation N +func testOpXDR(n int) string { + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("opxdr%d", n))) +} + func TestQueryResolver_TransactionByHash(t *testing.T) { mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("ObserveDBQueryDuration", "GetByHash", "transactions", mock.Anything).Return() @@ -32,10 +40,10 @@ func TestQueryResolver_TransactionByHash(t *testing.T) { t.Run("success", func(t *testing.T) { ctx := getTestCtx("transactions", []string{"hash", "toId", "envelopeXdr", "feeCharged", "resultCode", "metaXdr", "ledgerNumber", "ledgerCreatedAt", "isFeeBump"}) - tx, err := resolver.TransactionByHash(ctx, "tx1") + tx, err := resolver.TransactionByHash(ctx, testTxHash1) require.NoError(t, err) - assert.Equal(t, "tx1", tx.Hash) + assert.Equal(t, testTxHash1, tx.Hash.String()) assert.Equal(t, toid.New(1000, 1, 0).ToInt64(), tx.ToID) require.NotNil(t, tx.EnvelopeXDR) assert.Equal(t, "envelope1", *tx.EnvelopeXDR) @@ -48,10 +56,21 @@ func TestQueryResolver_TransactionByHash(t *testing.T) { t.Run("non-existent hash", func(t *testing.T) { ctx := getTestCtx("transactions", []string{"hash"}) - tx, err := resolver.TransactionByHash(ctx, "non-existent-hash") + tx, err := resolver.TransactionByHash(ctx, "0000000000000000000000000000000000000000000000000000000000000000") + + require.Error(t, err) + assert.Nil(t, tx) + }) + + t.Run("invalid hash format", func(t *testing.T) { + ctx := getTestCtx("transactions", []string{"hash"}) + tx, err := resolver.TransactionByHash(ctx, "not-a-valid-hash") require.Error(t, err) assert.Nil(t, tx) + var gqlErr *gqlerror.Error + require.ErrorAs(t, err, &gqlErr) + assert.Equal(t, ErrMsgInvalidTransactionHash, gqlErr.Message) }) t.Run("empty hash", func(t *testing.T) { @@ -86,10 +105,10 @@ func TestQueryResolver_Transactions(t *testing.T) { require.NoError(t, err) require.Len(t, transactions.Edges, 4) - assert.Equal(t, "tx1", transactions.Edges[0].Node.Hash) - assert.Equal(t, "tx2", transactions.Edges[1].Node.Hash) - assert.Equal(t, "tx3", transactions.Edges[2].Node.Hash) - assert.Equal(t, "tx4", transactions.Edges[3].Node.Hash) + assert.Equal(t, testTxHash1, transactions.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash2, transactions.Edges[1].Node.Hash.String()) + assert.Equal(t, testTxHash3, transactions.Edges[2].Node.Hash.String()) + assert.Equal(t, testTxHash4, transactions.Edges[3].Node.Hash.String()) }) t.Run("get transactions with first/after limit and cursor", func(t *testing.T) { @@ -98,8 +117,8 @@ func TestQueryResolver_Transactions(t *testing.T) { txs, err := resolver.Transactions(ctx, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 2) - assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) - assert.Equal(t, "tx2", txs.Edges[1].Node.Hash) + assert.Equal(t, testTxHash1, txs.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash2, txs.Edges[1].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.False(t, txs.PageInfo.HasPreviousPage) @@ -110,7 +129,7 @@ func TestQueryResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 1) - assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) + assert.Equal(t, testTxHash3, txs.Edges[0].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) @@ -121,7 +140,7 @@ func TestQueryResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 1) - assert.Equal(t, "tx4", txs.Edges[0].Node.Hash) + assert.Equal(t, testTxHash4, txs.Edges[0].Node.Hash.String()) assert.False(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) }) @@ -132,8 +151,8 @@ func TestQueryResolver_Transactions(t *testing.T) { txs, err := resolver.Transactions(ctx, nil, nil, &last, nil) require.NoError(t, err) assert.Len(t, txs.Edges, 2) - assert.Equal(t, "tx3", txs.Edges[0].Node.Hash) - assert.Equal(t, "tx4", txs.Edges[1].Node.Hash) + assert.Equal(t, testTxHash3, txs.Edges[0].Node.Hash.String()) + assert.Equal(t, testTxHash4, txs.Edges[1].Node.Hash.String()) assert.False(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) @@ -144,7 +163,7 @@ func TestQueryResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, txs.Edges, 1) - assert.Equal(t, "tx2", txs.Edges[0].Node.Hash) + assert.Equal(t, testTxHash2, txs.Edges[0].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.True(t, txs.PageInfo.HasPreviousPage) @@ -154,7 +173,7 @@ func TestQueryResolver_Transactions(t *testing.T) { txs, err = resolver.Transactions(ctx, nil, nil, &last, nextCursor) require.NoError(t, err) assert.Len(t, txs.Edges, 1) - assert.Equal(t, "tx1", txs.Edges[0].Node.Hash) + assert.Equal(t, testTxHash1, txs.Edges[0].Node.Hash.String()) assert.True(t, txs.PageInfo.HasNextPage) assert.False(t, txs.PageInfo.HasPreviousPage) }) @@ -278,10 +297,10 @@ func TestQueryResolver_Operations(t *testing.T) { require.NoError(t, err) require.Len(t, operations.Edges, 8) // Operations are ordered by ID ascending - assert.Equal(t, "opxdr1", operations.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", operations.Edges[1].Node.OperationXDR) - assert.Equal(t, "opxdr3", operations.Edges[2].Node.OperationXDR) - assert.Equal(t, "opxdr4", operations.Edges[3].Node.OperationXDR) + assert.Equal(t, testOpXDR(1), operations.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(2), operations.Edges[1].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(3), operations.Edges[2].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(4), operations.Edges[3].Node.OperationXDR.String()) }) t.Run("get operations with first/after limit and cursor", func(t *testing.T) { @@ -290,8 +309,8 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err := resolver.Operations(ctx, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 2) - assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDR(1), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(2), ops.Edges[1].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) @@ -302,7 +321,7 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, "opxdr3", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, testOpXDR(3), ops.Edges[0].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -313,11 +332,11 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 5) - assert.Equal(t, "opxdr4", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr5", ops.Edges[1].Node.OperationXDR) - assert.Equal(t, "opxdr6", ops.Edges[2].Node.OperationXDR) - assert.Equal(t, "opxdr7", ops.Edges[3].Node.OperationXDR) - assert.Equal(t, "opxdr8", ops.Edges[4].Node.OperationXDR) + assert.Equal(t, testOpXDR(4), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(5), ops.Edges[1].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(6), ops.Edges[2].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(7), ops.Edges[3].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(8), ops.Edges[4].Node.OperationXDR.String()) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) }) @@ -329,8 +348,8 @@ func TestQueryResolver_Operations(t *testing.T) { require.NoError(t, err) assert.Len(t, ops.Edges, 2) // With backward pagination, we get the last 2 items - assert.Equal(t, "opxdr7", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr8", ops.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDR(7), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(8), ops.Edges[1].Node.OperationXDR.String()) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -341,7 +360,7 @@ func TestQueryResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, nil, nil, &last, prevCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, "opxdr6", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, testOpXDR(6), ops.Edges[0].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -352,11 +371,11 @@ func TestQueryResolver_Operations(t *testing.T) { require.NoError(t, err) // There are 5 operations before (2,1): (2,2), (3,1), (3,2), (4,1), (4,2) assert.Len(t, ops.Edges, 5) - assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", ops.Edges[1].Node.OperationXDR) - assert.Equal(t, "opxdr3", ops.Edges[2].Node.OperationXDR) - assert.Equal(t, "opxdr4", ops.Edges[3].Node.OperationXDR) - assert.Equal(t, "opxdr5", ops.Edges[4].Node.OperationXDR) + assert.Equal(t, testOpXDR(1), ops.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(2), ops.Edges[1].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(3), ops.Edges[2].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(4), ops.Edges[3].Node.OperationXDR.String()) + assert.Equal(t, testOpXDR(5), ops.Edges[4].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) }) @@ -442,7 +461,7 @@ func TestQueryResolver_OperationByID(t *testing.T) { require.NoError(t, err) assert.Equal(t, toid.New(1000, 1, 1).ToInt64(), op.ID) - assert.Equal(t, "opxdr1", op.OperationXDR) + assert.Equal(t, testOpXDR(1), op.OperationXDR.String()) assert.Equal(t, uint32(1), op.LedgerNumber) }) diff --git a/internal/serve/graphql/resolvers/statechange.resolvers.go b/internal/serve/graphql/resolvers/statechange.resolvers.go index 3a969a8d..fc54bc98 100644 --- a/internal/serve/graphql/resolvers/statechange.resolvers.go +++ b/internal/serve/graphql/resolvers/statechange.resolvers.go @@ -69,7 +69,7 @@ func (r *balanceAuthorizationChangeResolver) Transaction(ctx context.Context, ob // TokenID is the resolver for the tokenId field. func (r *balanceAuthorizationChangeResolver) TokenID(ctx context.Context, obj *types.BalanceAuthorizationStateChangeModel) (*string, error) { - return r.resolveNullableString(obj.TokenID), nil + return r.resolveNullableAddress(obj.TokenID), nil } // LiquidityPoolID is the resolver for the liquidityPoolId field. @@ -197,7 +197,7 @@ func (r *reservesChangeResolver) ClaimableBalanceID(ctx context.Context, obj *ty // SponsoredTrustline is the resolver for the sponsoredTrustline field. func (r *reservesChangeResolver) SponsoredTrustline(ctx context.Context, obj *types.ReservesStateChangeModel) (*string, error) { - return r.resolveNullableString(obj.TokenID), nil + return r.resolveNullableAddress(obj.TokenID), nil } // SponsoredData is the resolver for the sponsoredData field. @@ -330,7 +330,7 @@ func (r *standardBalanceChangeResolver) Transaction(ctx context.Context, obj *ty // TokenID is the resolver for the tokenId field. func (r *standardBalanceChangeResolver) TokenID(ctx context.Context, obj *types.StandardBalanceStateChangeModel) (string, error) { - return r.resolveRequiredString(obj.TokenID), nil + return obj.TokenID.String(), nil } // Amount is the resolver for the amount field. @@ -365,7 +365,7 @@ func (r *trustlineChangeResolver) Transaction(ctx context.Context, obj *types.Tr // TokenID is the resolver for the tokenId field. func (r *trustlineChangeResolver) TokenID(ctx context.Context, obj *types.TrustlineStateChangeModel) (*string, error) { - return r.resolveNullableString(obj.TokenID), nil + return r.resolveNullableAddress(obj.TokenID), nil } // Limit is the resolver for the limit field. diff --git a/internal/serve/graphql/resolvers/statechange_resolvers_test.go b/internal/serve/graphql/resolvers/statechange_resolvers_test.go index 39a04468..54ac4509 100644 --- a/internal/serve/graphql/resolvers/statechange_resolvers_test.go +++ b/internal/serve/graphql/resolvers/statechange_resolvers_test.go @@ -25,14 +25,14 @@ func TestStateChangeResolver_NullableStringFields(t *testing.T) { t.Run("all valid", func(t *testing.T) { obj := &types.StandardBalanceStateChangeModel{ StateChange: types.StateChange{ - TokenID: sql.NullString{String: "token1", Valid: true}, + TokenID: types.NullAddressBytea{AddressBytea: types.AddressBytea(MainnetNativeContractAddress), Valid: true}, Amount: sql.NullString{String: "100.5", Valid: true}, }, } tokenID, err := resolver.TokenID(ctx, obj) require.NoError(t, err) - assert.Equal(t, "token1", tokenID) + assert.Equal(t, MainnetNativeContractAddress, tokenID) amount, err := resolver.Amount(ctx, obj) require.NoError(t, err) @@ -376,7 +376,7 @@ func TestStateChangeResolver_Transaction(t *testing.T) { tx, err := resolver.Transaction(ctx, &parentSC) require.NoError(t, err) - assert.Equal(t, "tx1", tx.Hash) + assert.Equal(t, testTxHash1, tx.Hash.String()) }) t.Run("nil state change panics", func(t *testing.T) { diff --git a/internal/serve/graphql/resolvers/test_utils.go b/internal/serve/graphql/resolvers/test_utils.go index ef181e00..e9f1d4c6 100644 --- a/internal/serve/graphql/resolvers/test_utils.go +++ b/internal/serve/graphql/resolvers/test_utils.go @@ -53,6 +53,15 @@ var sharedTestAccountAddress = keypair.MustRandom().Address() // sharedNonExistentAccountAddress is a valid Stellar address that doesn't exist in the test DB. var sharedNonExistentAccountAddress = keypair.MustRandom().Address() +// Test transaction hashes used by setupDB (64-char hex strings for BYTEA storage) +// These must match the pattern in setupDB: fmt.Sprintf("...487%x", i) where i = 0,1,2,3 +var ( + testTxHash1 = "3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4870" + testTxHash2 = "3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4871" + testTxHash3 = "3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4872" + testTxHash4 = "3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4873" +) + func setupDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPool) { testLedger := int32(1000) parentAccount := &types.Account{StellarAddress: types.AddressBytea(sharedTestAccountAddress)} @@ -61,7 +70,7 @@ func setupDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPo opIdx := 1 for i := range 4 { txn := &types.Transaction{ - Hash: fmt.Sprintf("tx%d", i+1), + Hash: types.HashBytea(fmt.Sprintf("3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa487%x", i)), ToID: toid.New(testLedger, int32(i+1), 0).ToInt64(), EnvelopeXDR: ptr(fmt.Sprintf("envelope%d", i+1)), FeeCharged: int64(100 * (i + 1)), @@ -78,7 +87,7 @@ func setupDB(ctx context.Context, t *testing.T, dbConnectionPool db.ConnectionPo ops = append(ops, &types.Operation{ ID: toid.New(testLedger, int32(i+1), int32(j+1)).ToInt64(), OperationType: "PAYMENT", - OperationXDR: fmt.Sprintf("opxdr%d", opIdx), + OperationXDR: types.XDRBytea([]byte(fmt.Sprintf("opxdr%d", opIdx))), ResultCode: "op_success", Successful: true, LedgerNumber: 1, diff --git a/internal/serve/graphql/resolvers/transaction.resolvers.go b/internal/serve/graphql/resolvers/transaction.resolvers.go index 7d83bf8f..eeb7dec2 100644 --- a/internal/serve/graphql/resolvers/transaction.resolvers.go +++ b/internal/serve/graphql/resolvers/transaction.resolvers.go @@ -15,6 +15,11 @@ import ( "github.com/stellar/wallet-backend/internal/serve/middleware" ) +// Hash is the resolver for the hash field. +func (r *transactionResolver) Hash(ctx context.Context, obj *types.Transaction) (string, error) { + return obj.Hash.String(), nil +} + // Operations is the resolver for the operations field. // This is a field resolver for the "operations" field on a Transaction object // It's called when a GraphQL query requests the operations within a transaction diff --git a/internal/serve/graphql/resolvers/transaction_resolvers_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index 17df19e4..394d3205 100644 --- a/internal/serve/graphql/resolvers/transaction_resolvers_test.go +++ b/internal/serve/graphql/resolvers/transaction_resolvers_test.go @@ -2,6 +2,8 @@ package resolvers import ( "context" + "encoding/base64" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -17,6 +19,11 @@ import ( "github.com/stellar/wallet-backend/internal/serve/middleware" ) +// testOpXDR returns the expected base64-encoded XDR for test operation N +func testOpXDRTx(n int) string { + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("opxdr%d", n))) +} + func TestTransactionResolver_Operations(t *testing.T) { mockMetricsService := &metrics.MockMetricsService{} mockMetricsService.On("IncDBQuery", "BatchGetByToID", "operations").Return() @@ -32,7 +39,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }, }} // ToID=toid.New(1000, 1, 0) matches the test data setup in test_utils.go (testLedger=1000, i=0) - parentTx := &types.Transaction{Hash: "tx1", ToID: toid.New(1000, 1, 0).ToInt64()} + parentTx := &types.Transaction{Hash: types.HashBytea(testTxHash1), ToID: toid.New(1000, 1, 0).ToInt64()} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) @@ -42,8 +49,8 @@ func TestTransactionResolver_Operations(t *testing.T) { require.NoError(t, err) require.Len(t, operations.Edges, 2) - assert.Equal(t, "opxdr1", operations.Edges[0].Node.OperationXDR) - assert.Equal(t, "opxdr2", operations.Edges[1].Node.OperationXDR) + assert.Equal(t, testOpXDRTx(1), operations.Edges[0].Node.OperationXDR.String()) + assert.Equal(t, testOpXDRTx(2), operations.Edges[1].Node.OperationXDR.String()) }) t.Run("nil transaction panics", func(t *testing.T) { @@ -56,7 +63,7 @@ func TestTransactionResolver_Operations(t *testing.T) { }) t.Run("transaction with no operations", func(t *testing.T) { - nonExistentTx := &types.Transaction{Hash: "non-existent-tx", ToID: 999999} + nonExistentTx := &types.Transaction{Hash: "2376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 999999} loaders := dataloaders.NewDataloaders(resolver.models) ctx := context.WithValue(getTestCtx("operations", []string{"id"}), middleware.LoadersKey, loaders) @@ -73,7 +80,7 @@ func TestTransactionResolver_Operations(t *testing.T) { ops, err := resolver.Operations(ctx, parentTx, &first, nil, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, testOpXDRTx(1), ops.Edges[0].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) @@ -83,7 +90,7 @@ func TestTransactionResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentTx, &first, nextCursor, nil, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, "opxdr2", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, testOpXDRTx(2), ops.Edges[0].Node.OperationXDR.String()) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) }) @@ -95,7 +102,7 @@ func TestTransactionResolver_Operations(t *testing.T) { ops, err := resolver.Operations(ctx, parentTx, nil, nil, &last, nil) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, "opxdr2", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, testOpXDRTx(2), ops.Edges[0].Node.OperationXDR.String()) assert.False(t, ops.PageInfo.HasNextPage) assert.True(t, ops.PageInfo.HasPreviousPage) @@ -105,7 +112,7 @@ func TestTransactionResolver_Operations(t *testing.T) { ops, err = resolver.Operations(ctx, parentTx, nil, nil, &last, prevCursor) require.NoError(t, err) assert.Len(t, ops.Edges, 1) - assert.Equal(t, "opxdr1", ops.Edges[0].Node.OperationXDR) + assert.Equal(t, testOpXDRTx(1), ops.Edges[0].Node.OperationXDR.String()) assert.True(t, ops.PageInfo.HasNextPage) assert.False(t, ops.PageInfo.HasPreviousPage) }) @@ -167,7 +174,7 @@ func TestTransactionResolver_Accounts(t *testing.T) { }, }, }} - parentTx := &types.Transaction{ToID: toid.New(1000, 1, 0).ToInt64(), Hash: "tx1"} + parentTx := &types.Transaction{ToID: toid.New(1000, 1, 0).ToInt64(), Hash: "1376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877"} t.Run("success", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) @@ -190,7 +197,7 @@ func TestTransactionResolver_Accounts(t *testing.T) { }) t.Run("transaction with no associated accounts", func(t *testing.T) { - nonExistentTx := &types.Transaction{Hash: "non-existent-tx"} + nonExistentTx := &types.Transaction{Hash: "2376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877"} loaders := dataloaders.NewDataloaders(resolver.models) ctx := context.WithValue(getTestCtx("accounts", []string{"address"}), middleware.LoadersKey, loaders) @@ -219,8 +226,8 @@ func TestTransactionResolver_StateChanges(t *testing.T) { }, }, }} - parentTx := &types.Transaction{Hash: "tx1", ToID: toid.New(1000, 1, 0).ToInt64()} - nonExistentTx := &types.Transaction{Hash: "non-existent-tx", ToID: 0} + parentTx := &types.Transaction{Hash: "1376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: toid.New(1000, 1, 0).ToInt64()} + nonExistentTx := &types.Transaction{Hash: "2376b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4877", ToID: 0} t.Run("success without pagination", func(t *testing.T) { loaders := dataloaders.NewDataloaders(resolver.models) diff --git a/internal/serve/graphql/schema/operation.graphqls b/internal/serve/graphql/schema/operation.graphqls index 0755fc38..dcec1b37 100644 --- a/internal/serve/graphql/schema/operation.graphqls +++ b/internal/serve/graphql/schema/operation.graphqls @@ -3,7 +3,7 @@ type Operation{ id: Int64! operationType: OperationType! - operationXdr: String! + operationXdr: String! @goField(forceResolver: true) resultCode: String! successful: Boolean! ledgerNumber: UInt32! diff --git a/internal/serve/graphql/schema/transaction.graphqls b/internal/serve/graphql/schema/transaction.graphqls index 6a2bd458..a5c74d6a 100644 --- a/internal/serve/graphql/schema/transaction.graphqls +++ b/internal/serve/graphql/schema/transaction.graphqls @@ -1,7 +1,7 @@ # GraphQL Transaction type - represents a blockchain transaction # gqlgen generates Go structs from this schema definition type Transaction{ - hash: String! + hash: String! @goField(forceResolver: true) envelopeXdr: String feeCharged: Int64! resultCode: String! diff --git a/internal/services/ingest_test.go b/internal/services/ingest_test.go index 8606e661..3fe1b6af 100644 --- a/internal/services/ingest_test.go +++ b/internal/services/ingest_test.go @@ -41,6 +41,23 @@ var ( const ( defaultGetLedgersLimit = 50 + // Test hash constants for ingest tests (64-char hex strings for BYTEA storage) + flushTxHash1 = "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f101" + flushTxHash2 = "f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f202" + flushTxHash3 = "f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f303" + flushTxHash4 = "f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f404" + flushTxHash5 = "f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f505" + flushTxHash6 = "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f606" + catchupTxHash1 = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c101" + catchupTxHash2 = "c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c202" + catchupTxHash3 = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c303" + catchupTxHash4 = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c404" + catchupTxHash5 = "c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c505" + catchupTxHash6 = "c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c606" + prevTxHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + txHash1 = "1111111111111111111111111111111111111111111111111111111111111111" + txHash2 = "2222222222222222222222222222222222222222222222222222222222222222" + // Test fixtures for ledger metadata ledgerMetadataWith0Tx = "AAAAAQAAAACB7Zh2o0NTFwl1nvs7xr3SJ7w8PpwnSRb8QyG9k6acEwAAABaeASPlzu/ZFxwyyWsxtGoj3KCrybm2yN7WOweR0BWdLYjyoO5BI41g1PFT+iHW68giP49Koo+q3VmH8I4GdtW2AAAAAGhTTB8AAAAAAAAAAQAAAAC1XRCyu30oTtXAOkel4bWQyQ9Xg1VHHMRQe76CBNI8iwAAAEDSH4sE7cL7UJyOqUo9ZZeNqPT7pt7su8iijHjWYg4MbeFUh/gkGf6N40bZjP/dlIuGXmuEhWoEX0VTV58xOB4C3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERm+pITz+1V1m+3/v6eaEKglCnon3a5xkn02sLltJ9CSzwAAEYIN4Lazp2QAAAAAAAMtYtQzAAAAAAAAAAAAAAAMAAAAZABMS0AAAADIXukLfWC53MCmzxKd/+LBbaYxQkgxATFDLI3hWj7EqWgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGeASPlzu/ZFxwyyWsxtGoj3KCrybm2yN7WOweR0BWdLQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9yHMAAAAAAAAAAA==" ledgerMetadataWith1Tx = "AAAAAQAAAAD8G2qemHnBKFkbq90RTagxAypNnA7DXDc63Giipq9mNwAAABYLEZ5DrTv6njXTOAFEdOO0yeLtJjCRyH4ryJkgpRh7VPJvwbisrc9A0yzFxxCdkICgB3Gv7qHOi8ZdsK2CNks2AAAAAGhTTAsAAAAAAAAAAQAAAACoJM0YvJ11Bk0pmltbrKQ7w6ovMmk4FT2ML5u1y23wMwAAAEAunZtorOSbnRpgnykoDe4kzAvLwNXefncy1R/1ynBWyDv0DfdnqJ6Hcy/0AJf6DkBZlRayg775h3HjV0GKF/oPua7l8wkLlJBtSk1kRDt55qSf6btSrgcupB/8bnpJfUUgZJ76saUrj29HukYHS1bq7SyuoCAY+5F9iBYTmW1G9QAAEX4N4Lazp2QAAAAAAAMtS3veAAAAAAAAAAAAAAAMAAAAZABMS0AAAADIXukLfWC53MCmzxKd/+LBbaYxQkgxATFDLI3hWj7EqWgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAELEZ5DrTv6njXTOAFEdOO0yeLtJjCRyH4ryJkgpRh7VAAAAAIAAAAAAAAAAQAAAAAAAAABAAAAAAAAAGQAAAABAAAAAgAAAADg4mtiLKjJVgrmOpO9+Ff3XAmnycHyNUKu/v9KhHevAAAAAGQAAA7FAAAAGgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAALvqzdVyRxgBMcLzbw1wNWcJYHPNPok1GdVSgmy4sjR2AAAAAVVTREMAAAAA4OJrYiyoyVYK5jqTvfhX91wJp8nB8jVCrv7/SoR3rwAAAAACVAvkAAAAAAAAAAABhHevAAAAAEDq2yIDzXUoLboBHQkbr8U2oKqLzf0gfpwXbmRPLB6Ek3G8uCEYyry1vt5Sb+LCEd81fefFQcQN0nydr1FmiXcDAAAAAAAAAAAAAAABXFSiWcxpDRa8frBs1wbEaMUw4hMe7ctFtdw3Ci73IEwAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAIAAAADAAARfQAAAAAAAAAA4OJrYiyoyVYK5jqTvfhX91wJp8nB8jVCrv7/SoR3rwAAAAAukO3GPAAADsUAAAAZAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAAABF9AAAAAGhTTAYAAAAAAAAAAQAAEX4AAAAAAAAAAODia2IsqMlWCuY6k734V/dcCafJwfI1Qq7+/0qEd68AAAAALpDtxdgAAA7FAAAAGQAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAAAARfQAAAABoU0wGAAAAAAAAAAMAAAAAAAAAAgAAAAMAABF+AAAAAAAAAADg4mtiLKjJVgrmOpO9+Ff3XAmnycHyNUKu/v9KhHevAAAAAC6Q7cXYAAAOxQAAABkAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAAAEX0AAAAAaFNMBgAAAAAAAAABAAARfgAAAAAAAAAA4OJrYiyoyVYK5jqTvfhX91wJp8nB8jVCrv7/SoR3rwAAAAAukO3F2AAADsUAAAAaAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAAABF+AAAAAGhTTAsAAAAAAAAAAQAAAAIAAAADAAARcwAAAAEAAAAAu+rN1XJHGAExwvNvDXA1Zwlgc80+iTUZ1VKCbLiyNHYAAAABVVNEQwAAAADg4mtiLKjJVgrmOpO9+Ff3XAmnycHyNUKu/v9KhHevAAAAAAlQL5AAf/////////8AAAABAAAAAAAAAAAAAAABAAARfgAAAAEAAAAAu+rN1XJHGAExwvNvDXA1Zwlgc80+iTUZ1VKCbLiyNHYAAAABVVNEQwAAAADg4mtiLKjJVgrmOpO9+Ff3XAmnycHyNUKu/v9KhHevAAAAAAukO3QAf/////////8AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8RxEAAAAAAAAAAA==" @@ -506,7 +523,7 @@ func createTestTransaction(hash string, toID int64) types.Transaction { envelope := "test_envelope_xdr" meta := "test_meta_xdr" return types.Transaction{ - Hash: hash, + Hash: types.HashBytea(hash), ToID: toID, EnvelopeXDR: &envelope, FeeCharged: 100, @@ -524,7 +541,7 @@ func createTestOperation(id int64) types.Operation { return types.Operation{ ID: id, OperationType: types.OperationTypePayment, - OperationXDR: "test_operation_xdr", + OperationXDR: types.XDRBytea([]byte("test_operation_xdr")), LedgerNumber: 1000, LedgerCreatedAt: now, IngestedAt: now, @@ -1150,8 +1167,8 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { name: "flush_with_data_inserts_to_database", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("flush_tx_1", 1) - tx2 := createTestTransaction("flush_tx_2", 2) + tx1 := createTestTransaction(flushTxHash1, 1) + tx2 := createTestTransaction(flushTxHash2, 2) op1 := createTestOperation(200) op2 := createTestOperation(201) sc1 := createTestStateChange(1, testAddr1, 200) @@ -1171,13 +1188,13 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { wantTxCount: 2, wantOpCount: 2, wantStateChangeCount: 2, - txHashes: []string{"flush_tx_1", "flush_tx_2"}, + txHashes: []string{flushTxHash1, flushTxHash2}, }, { name: "flush_with_cursor_update_to_lower_value", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("flush_tx_3", 3) + tx1 := createTestTransaction(flushTxHash3, 3) buf.PushTransaction(testAddr1, tx1) return buf }, @@ -1187,13 +1204,13 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { wantTxCount: 1, wantOpCount: 0, wantStateChangeCount: 0, - txHashes: []string{"flush_tx_3"}, + txHashes: []string{flushTxHash3}, }, { name: "flush_with_cursor_update_to_higher_value_keeps_existing", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("flush_tx_4", 4) + tx1 := createTestTransaction(flushTxHash4, 4) buf.PushTransaction(testAddr1, tx1) return buf }, @@ -1203,14 +1220,14 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { wantTxCount: 1, wantOpCount: 0, wantStateChangeCount: 0, - txHashes: []string{"flush_tx_4"}, + txHashes: []string{flushTxHash4}, }, { name: "flush_with_filtering_only_inserts_registered", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("flush_tx_5", 5) // Registered participant - tx2 := createTestTransaction("flush_tx_6", 6) // No registered participant + tx1 := createTestTransaction(flushTxHash5, 5) // Registered participant + tx2 := createTestTransaction(flushTxHash6, 6) // No registered participant buf.PushTransaction(testAddr1, tx1) buf.PushTransaction(testAddrUnreg, tx2) @@ -1224,17 +1241,17 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { wantTxCount: 1, // Only tx1 wantOpCount: 0, wantStateChangeCount: 0, - txHashes: []string{"flush_tx_5"}, + txHashes: []string{flushTxHash5}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Clean up test data from previous runs - for _, hash := range []string{"flush_tx_1", "flush_tx_2", "flush_tx_3", "flush_tx_4", "flush_tx_5", "flush_tx_6"} { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes WHERE to_id IN (SELECT to_id FROM transactions WHERE hash = $1)`, hash) + // Clean up test data from previous runs (using HashBytea for BYTEA column) + for _, hash := range []string{flushTxHash1, flushTxHash2, flushTxHash3, flushTxHash4, flushTxHash5, flushTxHash6} { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes WHERE to_id IN (SELECT to_id FROM transactions WHERE hash = $1)`, types.HashBytea(hash)) require.NoError(t, err) - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions WHERE hash = $1`, hash) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions WHERE hash = $1`, types.HashBytea(hash)) require.NoError(t, err) } // Also clean up any orphan operations @@ -1308,10 +1325,16 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { // Verify transaction count in database if len(tc.txHashes) > 0 { + hashBytes := make([][]byte, len(tc.txHashes)) + for i, h := range tc.txHashes { + val, err := types.HashBytea(h).Value() + require.NoError(t, err) + hashBytes[i] = val.([]byte) + } var txCount int err = dbConnectionPool.GetContext(ctx, &txCount, `SELECT COUNT(*) FROM transactions WHERE hash = ANY($1)`, - pq.Array(tc.txHashes)) + pq.Array(hashBytes)) require.NoError(t, err) assert.Equal(t, tc.wantTxCount, txCount, "transaction count mismatch") } @@ -1327,10 +1350,16 @@ func Test_ingestService_flushBatchBufferWithRetry(t *testing.T) { // Verify state change count in database if tc.wantStateChangeCount > 0 { + scHashBytes := make([][]byte, len(tc.txHashes)) + for i, h := range tc.txHashes { + val, err := types.HashBytea(h).Value() + require.NoError(t, err) + scHashBytes[i] = val.([]byte) + } var scCount int err = dbConnectionPool.GetContext(ctx, &scCount, `SELECT COUNT(*) FROM state_changes WHERE to_id IN (SELECT to_id FROM transactions WHERE hash = ANY($1))`, - pq.Array(tc.txHashes)) + pq.Array(scHashBytes)) require.NoError(t, err) assert.Equal(t, tc.wantStateChangeCount, scCount, "state change count mismatch") } @@ -1362,8 +1391,8 @@ func Test_ingestService_filterParticipantData(t *testing.T) { enableParticipantFiltering: false, setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("tx_hash_1", 1) - tx2 := createTestTransaction("tx_hash_2", 2) + tx1 := createTestTransaction(txHash1, 1) + tx2 := createTestTransaction(txHash2, 2) op1 := createTestOperation(100) op2 := createTestOperation(101) sc1 := createTestStateChange(1, testAddr1, 100) @@ -1387,7 +1416,7 @@ func Test_ingestService_filterParticipantData(t *testing.T) { registeredAccounts: []string{testAddr1}, setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("tx_hash_1", 1) + tx1 := createTestTransaction(txHash1, 1) op1 := createTestOperation(100) sc1 := createTestStateChange(1, testAddr1, 100) @@ -1415,8 +1444,8 @@ func Test_ingestService_filterParticipantData(t *testing.T) { registeredAccounts: []string{testAddr1}, setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("tx_hash_1", 1) // Has registered - tx2 := createTestTransaction("tx_hash_2", 2) // No registered + tx1 := createTestTransaction(txHash1, 1) // Has registered + tx2 := createTestTransaction(txHash2, 2) // No registered op1 := createTestOperation(100) op2 := createTestOperation(101) @@ -1436,7 +1465,7 @@ func Test_ingestService_filterParticipantData(t *testing.T) { registeredAccounts: []string{}, setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("tx_hash_1", 1) + tx1 := createTestTransaction(txHash1, 1) buf.PushTransaction(testAddrUnreg, tx1) return buf }, @@ -1450,7 +1479,7 @@ func Test_ingestService_filterParticipantData(t *testing.T) { registeredAccounts: []string{testAddr1, testAddr2}, setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("tx_hash_1", 1) + tx1 := createTestTransaction(txHash1, 1) op1 := createTestOperation(100) // 3 state changes: 2 for registered accounts, 1 for unregistered @@ -2648,7 +2677,7 @@ func Test_ingestService_flushBatchBuffer_batchChanges(t *testing.T) { name: "collects_trustline_changes_when_batchChanges_provided", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("catchup_tx_1", 1) + tx1 := createTestTransaction(catchupTxHash1, 1) buf.PushTransaction(testAddr1, tx1) buf.PushTrustlineChange(types.TrustlineChange{ AccountID: testAddr1, @@ -2667,7 +2696,7 @@ func Test_ingestService_flushBatchBuffer_batchChanges(t *testing.T) { name: "collects_contract_changes_when_batchChanges_provided", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("catchup_tx_2", 2) + tx1 := createTestTransaction(catchupTxHash2, 2) buf.PushTransaction(testAddr2, tx1) buf.PushContractChange(types.ContractChange{ AccountID: testAddr2, @@ -2694,7 +2723,7 @@ func Test_ingestService_flushBatchBuffer_batchChanges(t *testing.T) { name: "nil_batchChanges_does_not_collect", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("catchup_tx_5", 5) + tx1 := createTestTransaction(catchupTxHash5, 5) buf.PushTransaction(testAddr1, tx1) buf.PushTrustlineChange(types.TrustlineChange{ AccountID: testAddr1, @@ -2713,7 +2742,7 @@ func Test_ingestService_flushBatchBuffer_batchChanges(t *testing.T) { name: "accumulates_across_multiple_flushes", setupBuffer: func() *indexer.IndexerBuffer { buf := indexer.NewIndexerBuffer() - tx1 := createTestTransaction("catchup_tx_6", 6) + tx1 := createTestTransaction(catchupTxHash6, 6) buf.PushTransaction(testAddr1, tx1) buf.PushTrustlineChange(types.TrustlineChange{ AccountID: testAddr1, @@ -2739,11 +2768,11 @@ func Test_ingestService_flushBatchBuffer_batchChanges(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Clean up test data from previous runs - for _, hash := range []string{"catchup_tx_1", "catchup_tx_2", "catchup_tx_3", "catchup_tx_4", "catchup_tx_5", "catchup_tx_6", "prev_tx"} { - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes WHERE to_id IN (SELECT to_id FROM transactions WHERE hash = $1)`, hash) + // Clean up test data from previous runs (using HashBytea for BYTEA column) + for _, hash := range []string{catchupTxHash1, catchupTxHash2, catchupTxHash3, catchupTxHash4, catchupTxHash5, catchupTxHash6, prevTxHash} { + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM state_changes WHERE to_id IN (SELECT to_id FROM transactions WHERE hash = $1)`, types.HashBytea(hash)) require.NoError(t, err) - _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions WHERE hash = $1`, hash) + _, err = dbConnectionPool.ExecContext(ctx, `DELETE FROM transactions WHERE hash = $1`, types.HashBytea(hash)) require.NoError(t, err) } // Also clean up any orphan operations diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 76e45561..658c71e8 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -3,6 +3,7 @@ package utils import ( "bytes" "context" + "encoding/hex" "fmt" "io" "reflect" @@ -75,6 +76,15 @@ func IsValidStellarAddress(address string) bool { return errAccount == nil || errContract == nil } +// IsValidTransactionHash checks if the string is a valid 64-character hex-encoded transaction hash. +func IsValidTransactionHash(hash string) bool { + if len(hash) != 64 { + return false + } + _, err := hex.DecodeString(hash) + return err == nil +} + // DeferredClose is a function that closes an `io.Closer` resource and logs an error if it fails. func DeferredClose(ctx context.Context, closer io.Closer, errMsg string) { if err := closer.Close(); err != nil { diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 07491ff0..99b90731 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -103,3 +103,25 @@ func TestIsEmpty(t *testing.T) { }) } } + +func TestIsValidTransactionHash(t *testing.T) { + tests := []struct { + name string + hash string + valid bool + }{ + {"valid lowercase hex", "3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa4870", true}, + {"valid uppercase hex", "3476B7B0133690FBFB2DE8FA9CA2273CB4F2E29447E0CF0E14A5F82D0DAA4870", true}, + {"all zeros", "0000000000000000000000000000000000000000000000000000000000000000", true}, + {"empty", "", false}, + {"too short", "abcdef", false}, + {"too long", "3476b7b0133690fbfb2de8fa9ca2273cb4f2e29447e0cf0e14a5f82d0daa487000", false}, + {"non-hex characters", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", false}, + {"mixed valid length but non-hex", "non-existent-hashXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.valid, IsValidTransactionHash(tc.hash)) + }) + } +}