diff --git a/internal/data/accounts_test.go b/internal/data/accounts_test.go index f34b35f3..2e1abd43 100644 --- a/internal/data/accounts_test.go +++ b/internal/data/accounts_test.go @@ -327,7 +327,9 @@ func TestAccountModelBatchGetByOperationIDs(t *testing.T) { 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) @@ -422,7 +424,9 @@ func TestAccountModelBatchGetByStateChangeIDs(t *testing.T) { 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 36420617..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, } @@ -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, } @@ -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, } @@ -516,13 +516,16 @@ func TestOperationModel_GetAll(t *testing.T) { 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) @@ -571,16 +574,22 @@ func TestOperationModel_BatchGetByToIDs(t *testing.T) { // 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 { @@ -762,21 +771,24 @@ func TestOperationModel_BatchGetByToID(t *testing.T) { // 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) { @@ -819,13 +831,16 @@ func TestOperationModel_BatchGetByAccountAddresses(t *testing.T) { 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 @@ -868,12 +883,14 @@ func TestOperationModel_GetByID(t *testing.T) { 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() @@ -889,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) } @@ -934,13 +951,16 @@ func TestOperationModel_BatchGetByStateChangeIDs(t *testing.T) { 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 f326d73c..691e1121 100644 --- a/internal/data/statechanges.go +++ b/internal/data/statechanges.go @@ -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 @@ -312,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, @@ -365,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), @@ -463,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}, @@ -473,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, @@ -527,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 } } @@ -637,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 8267b5fa..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}, @@ -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{ @@ -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{ diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index bf15daea..489e5691 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -650,13 +650,16 @@ func TestTransactionModel_BatchGetByOperationIDs(t *testing.T) { // Create test operations (IDs must be in TOID range for each transaction) // 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 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 9cae55f0..0fdc525a 100644 --- a/internal/indexer/indexer_buffer.go +++ b/internal/indexer/indexer_buffer.go @@ -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/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 04c88b4f..c4cfb614 100644 --- a/internal/indexer/processors/utils.go +++ b/internal/indexer/processors/utils.go @@ -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 d76a4f70..091370b9 100644 --- a/internal/indexer/types/types.go +++ b/internal/indexer/types/types.go @@ -34,6 +34,7 @@ package types import ( "database/sql" "database/sql/driver" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -166,6 +167,41 @@ 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 ( @@ -372,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"` @@ -528,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/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 47cb64d3..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) @@ -1009,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 { @@ -2316,7 +2318,7 @@ type CreateFeeBumpTransactionPayload { type Operation{ id: Int64! operationType: OperationType! - operationXdr: String! + operationXdr: String! @goField(forceResolver: true) resultCode: String! successful: Boolean! ledgerNumber: UInt32! @@ -6822,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) @@ -6843,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") }, @@ -17012,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 { diff --git a/internal/serve/graphql/resolvers/account_resolvers_test.go b/internal/serve/graphql/resolvers/account_resolvers_test.go index 470287f2..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)} @@ -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) }) 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/queries_resolvers_test.go b/internal/serve/graphql/resolvers/queries_resolvers_test.go index bb139b9d..2be5511c 100644 --- a/internal/serve/graphql/resolvers/queries_resolvers_test.go +++ b/internal/serve/graphql/resolvers/queries_resolvers_test.go @@ -1,6 +1,8 @@ package resolvers import ( + "encoding/base64" + "fmt" "testing" "github.com/stellar/go-stellar-sdk/toid" @@ -12,6 +14,11 @@ import ( "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() @@ -278,10 +285,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 +297,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 +309,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 +320,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 +336,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 +348,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 +359,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 +449,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 8076e5ac..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) diff --git a/internal/serve/graphql/resolvers/test_utils.go b/internal/serve/graphql/resolvers/test_utils.go index 77a433a4..e9f1d4c6 100644 --- a/internal/serve/graphql/resolvers/test_utils.go +++ b/internal/serve/graphql/resolvers/test_utils.go @@ -87,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_test.go b/internal/serve/graphql/resolvers/transaction_resolvers_test.go index 75603147..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() @@ -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) { @@ -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) }) 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/services/ingest_test.go b/internal/services/ingest_test.go index f9264558..3fe1b6af 100644 --- a/internal/services/ingest_test.go +++ b/internal/services/ingest_test.go @@ -541,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,