diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index 60572f99..74c1d2c3 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -4,9 +4,9 @@ import ( "context" "crypto/ed25519" "crypto/rand" - "errors" "fmt" "math/big" + "strconv" "time" "api.audius.co/utils" @@ -22,9 +22,8 @@ import ( ) const ( - signedAuthMessage = "code" - codeLength = 10 - codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + codeLength = 10 + codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // rewardPoolDeadlineWindow is the number of blocks ahead of the // current height at which we set the deadline_block_height on @@ -35,11 +34,16 @@ const ( rewardPoolDeadlineWindow = 100 ) +// CreateRewardCodeRequest carries a signed Solana transaction containing a +// single Memo Program instruction whose data is a millisecond-epoch timestamp +// (as a UTF-8 decimal string). The transaction's fee-payer is the signer and +// must appear in RewardCodeAuthorizedKeys. The transaction is never submitted +// on-chain — it's used purely as a signing envelope so every wallet flow +// (hot wallets and hardware wallets like Ledger) can authenticate uniformly. type CreateRewardCodeRequest struct { - Timestamp int64 `json:"timestamp" validate:"omitempty,min=1"` - Signature string `json:"signature" validate:"required"` - Mint string `json:"mint" validate:"required"` - Amount int64 `json:"amount" validate:"required,min=1"` + SignedTransaction string `json:"signed_transaction" validate:"required"` + Mint string `json:"mint" validate:"required"` + Amount int64 `json:"amount" validate:"required,min=1"` } type CreateRewardCodeResponse struct { @@ -64,68 +68,25 @@ func generateCode() (string, error) { return string(result), nil } -// buildOffchainMessage wraps a message in Solana's SIMD-0048 off-chain -// message envelope. Hardware wallets (notably Ledger) refuse to sign -// arbitrary bytes, so wallets like Phantom wrap the message in this -// envelope before sending it to the device — meaning the signature is -// over the wrapped bytes rather than the raw message. -func buildOffchainMessage(message string, format byte) []byte { - msgBytes := []byte(message) - msgLen := uint16(len(msgBytes)) - - // Signing domain (16 bytes) + version (1) + format (1) + length (2) + message - buf := make([]byte, 0, 20+len(msgBytes)) - buf = append(buf, []byte("\xffsolana offchain")...) - buf = append(buf, 0x00) - buf = append(buf, format) - buf = append(buf, byte(msgLen), byte(msgLen>>8)) - buf = append(buf, msgBytes...) - return buf -} - -func verifySignature(signatureBase58 string, message string, authorizedPubKey string) (bool, error) { - // Decode the signature from base58 - signatureBytes, err := base58.Decode(signatureBase58) - if err != nil { - return false, err - } - - // Parse the expected public key - expectedPubKey, err := solana.PublicKeyFromBase58(authorizedPubKey) - if err != nil { - return false, err - } - - // Hot wallets sign the raw bytes directly. - if ed25519.Verify(expectedPubKey[:], []byte(message), signatureBytes) { - return true, nil - } - - // Hardware wallets (e.g. Ledger via Phantom) sign the SIMD-0048 wrapped - // message instead. Try all three message formats since wallets vary. - for _, format := range []byte{0, 1, 2} { - if ed25519.Verify(expectedPubKey[:], buildOffchainMessage(message, format), signatureBytes) { - return true, nil +// extractMemoTimestamp finds the first Memo Program instruction in the +// transaction and parses its data as a millisecond-epoch timestamp. Returns +// an error if no memo instruction is present or its data isn't a valid +// integer. +func extractMemoTimestamp(tx *solana.Transaction) (int64, error) { + for _, ix := range tx.Message.Instructions { + if int(ix.ProgramIDIndex) >= len(tx.Message.AccountKeys) { + continue } - } - - return false, nil -} - -func verifySignatureAgainstKeys(signatureBase58 string, message string, authorizedKeys []string) (string, error) { - for _, key := range authorizedKeys { - valid, err := verifySignature(signatureBase58, message, key) - if err != nil { - // If there's an error parsing the key or signature, continue to next key + if !tx.Message.AccountKeys[ix.ProgramIDIndex].Equals(solana.MemoProgramID) { continue } - if valid { - // Found a matching key - return key, nil + ts, err := strconv.ParseInt(string(ix.Data), 10, 64) + if err != nil { + return 0, fmt.Errorf("memo data is not a valid timestamp: %w", err) } + return ts, nil } - // No matching key found - return "", errors.New("unauthorized") + return 0, fmt.Errorf("no memo instruction found") } func (app *ApiServer) v1CreateRewardCode(c *fiber.Ctx) error { @@ -133,31 +94,58 @@ func (app *ApiServer) v1CreateRewardCode(c *fiber.Ctx) error { if err := app.ParseAndValidateBody(c, &req); err != nil { return err } - var message = signedAuthMessage - signatureIsSingleUse := req.Timestamp > 0 - if signatureIsSingleUse { - message = fmt.Sprintf("%d", req.Timestamp) - var signatureUsed bool - // Check if signature already used - if err := app.writePool.QueryRow(context.Background(), ` - SELECT EXISTS(SELECT 1 FROM reward_codes WHERE signature = $1) - `, req.Signature).Scan(&signatureUsed); err != nil { - app.logger.Error("Failed to query for existing verified signature", zap.Error(err)) - return fiber.NewError(fiber.StatusInternalServerError) - } else if signatureUsed { - return fiber.NewError(fiber.StatusBadRequest, "Duplicate signature") - } - timestamp := time.UnixMilli(req.Timestamp) - // Allow drift of timestamp - if time.Since(timestamp).Abs() > (12 * time.Hour) { - return fiber.NewError(fiber.StatusBadRequest, "Timestamp out of range") + // Decode the signed transaction + tx, err := solana.TransactionFromBase64(req.SignedTransaction) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid transaction: "+err.Error()) + } + + // Must carry exactly one signature (the authorizing signer / fee payer) + if len(tx.Signatures) != 1 || len(tx.Message.AccountKeys) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transaction must have exactly one signer") + } + + // Cryptographically verify the signature against the message bytes + if err := tx.VerifySignatures(); err != nil { + return fiber.NewError(fiber.StatusForbidden, "Invalid signature: "+err.Error()) + } + + // Fee payer (account index 0) is the authorizing signer + signer := tx.Message.AccountKeys[0].String() + signerAuthorized := false + for _, key := range app.config.RewardCodeAuthorizedKeys { + if key == signer { + signerAuthorized = true + break } } + if !signerAuthorized { + return fiber.NewError(fiber.StatusForbidden, "Unauthorized: signer "+signer+" not in allowlist") + } - _, err := verifySignatureAgainstKeys(req.Signature, message, app.config.RewardCodeAuthorizedKeys) + // Extract and validate the timestamp embedded in the memo instruction + timestamp, err := extractMemoTimestamp(tx) if err != nil { - return fiber.NewError(fiber.StatusForbidden, "Unauthorized: "+err.Error()) + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if time.Since(time.UnixMilli(timestamp)).Abs() > (12 * time.Hour) { + return fiber.NewError(fiber.StatusBadRequest, "Timestamp out of range") + } + + // The transaction signature uniquely identifies this signing event. + // We use it as the replay-prevention key. + txSig := tx.Signatures[0].String() + + var signatureUsed bool + if err := app.writePool.QueryRow(context.Background(), ` + SELECT EXISTS(SELECT 1 FROM reward_codes WHERE signature = $1) + `, txSig).Scan(&signatureUsed); err != nil { + app.logger.Error("Failed to query for existing signature", zap.Error(err)) + return fiber.NewError(fiber.StatusInternalServerError) + } + if signatureUsed { + return fiber.NewError(fiber.StatusBadRequest, "Duplicate signature") } // Generate a code @@ -166,15 +154,7 @@ func (app *ApiServer) v1CreateRewardCode(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate code: "+err.Error()) } - var codeSignature string - if signatureIsSingleUse { - codeSignature = req.Signature - } else { - codeSignature = "" - } - - // Use shared function to create reward code and insert into database - rewardAddress, err := app.createAndInsertRewardCode(context.Background(), code, req.Mint, req.Amount, "Launchpad", codeSignature) + rewardAddress, err := app.createAndInsertRewardCode(context.Background(), code, req.Mint, req.Amount, "Launchpad", txSig) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create reward code: "+err.Error()) } diff --git a/api/v1_create_reward_code_test.go b/api/v1_create_reward_code_test.go index 947bf441..34ce46f2 100644 --- a/api/v1_create_reward_code_test.go +++ b/api/v1_create_reward_code_test.go @@ -2,7 +2,6 @@ package api import ( "context" - "crypto/ed25519" "crypto/rand" "encoding/json" "fmt" @@ -11,14 +10,46 @@ import ( "api.audius.co/database" "github.com/gagliardetto/solana-go" - "github.com/mr-tron/base58" "github.com/stretchr/testify/assert" ) -// Helper function to create a valid signature for testing -func createValidSignature(privateKey ed25519.PrivateKey, message string) string { - signature := ed25519.Sign(privateKey, []byte(message)) - return base58.Encode(signature) +// buildSignedMemoTx constructs a transaction containing a single Memo +// Program instruction whose data is the millisecond timestamp encoded as +// UTF-8 decimal digits, signs it with privKey, and returns the base64 +// representation suitable for the request body. +func buildSignedMemoTx(t *testing.T, privKey solana.PrivateKey, timestamp int64) string { + t.Helper() + + memoIx := solana.NewInstruction( + solana.MemoProgramID, + solana.AccountMetaSlice{}, + []byte(fmt.Sprintf("%d", timestamp)), + ) + + // Random dummy blockhash — wallets and our server don't validate it + // against a live chain since the tx is never submitted. + var blockhash solana.Hash + _, err := rand.Read(blockhash[:]) + assert.NoError(t, err) + + tx, err := solana.NewTransaction( + []solana.Instruction{memoIx}, + blockhash, + solana.TransactionPayer(privKey.PublicKey()), + ) + assert.NoError(t, err) + + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privKey.PublicKey()) { + return &privKey + } + return nil + }) + assert.NoError(t, err) + + encoded, err := tx.ToBase64() + assert.NoError(t, err) + return encoded } func TestV1CreateRewardCode(t *testing.T) { @@ -39,216 +70,169 @@ func TestV1CreateRewardCode(t *testing.T) { database.Seed(app.pool.Replicas[0], fixtures) - // Save original config and restore after tests originalKeys := app.config.RewardCodeAuthorizedKeys defer func() { app.config.RewardCodeAuthorizedKeys = originalKeys }() - t.Run("Unauthorized with invalid signature", func(t *testing.T) { - _, wrongPrivateKey, err := ed25519.GenerateKey(rand.Reader) + t.Run("Successfully creates a reward code", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) - signature := createValidSignature(wrongPrivateKey, "code") + app.config.RewardCodeAuthorizedKeys = []string{privKey.PublicKey().String()} - requestBody := CreateRewardCodeRequest{ - Signature: signature, - Mint: "TestMint123", - Amount: 100, - } + signedTx := buildSignedMemoTx(t, privKey, time.Now().UnixMilli()) - body, err := json.Marshal(requestBody) + body, err := json.Marshal(CreateRewardCodeRequest{ + SignedTransaction: signedTx, + Mint: "TestMint123", + Amount: 500, + }) assert.NoError(t, err) - status, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ + var resp CreateRewardCodeResponse + status, respBody := testPost(t, app, "/v1/rewards/code", body, map[string]string{ "Content-Type": "application/json", - }) + }, &resp) + assert.Equal(t, 201, status, "Response body: %s", string(respBody)) + assert.Equal(t, "TestMint123", resp.Mint) + assert.Equal(t, int64(500), resp.Amount) + assert.Len(t, resp.Code, codeLength) - assert.Equal(t, 403, status, "Should return 403 for unauthorized signature") + var dbCode string + var dbRemainingUses int + err = app.pool.QueryRow(context.Background(), + "SELECT code, remaining_uses FROM reward_codes WHERE code = $1", resp.Code). + Scan(&dbCode, &dbRemainingUses) + assert.NoError(t, err) + assert.Equal(t, 1, dbRemainingUses) }) - t.Run("Missing required fields", func(t *testing.T) { - requestBody := map[string]interface{}{ - "mint": "TestMint123", - // Missing signature and amount - } - - body, err := json.Marshal(requestBody) + t.Run("Successfully creates a reward code with second authorized signer", func(t *testing.T) { + _, err := solana.NewRandomPrivateKey() + assert.NoError(t, err) + privKey2, err := solana.NewRandomPrivateKey() assert.NoError(t, err) + other, err := solana.NewRandomPrivateKey() + assert.NoError(t, err) + app.config.RewardCodeAuthorizedKeys = []string{other.PublicKey().String(), privKey2.PublicKey().String()} - status, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ - "Content-Type": "application/json", + signedTx := buildSignedMemoTx(t, privKey2, time.Now().UnixMilli()) + + body, err := json.Marshal(CreateRewardCodeRequest{ + SignedTransaction: signedTx, + Mint: "TestMint123", + Amount: 500, }) + assert.NoError(t, err) - assert.Equal(t, 400, status, "Should return 400 for missing required fields") + var resp CreateRewardCodeResponse + status, respBody := testPost(t, app, "/v1/rewards/code", body, map[string]string{ + "Content-Type": "application/json", + }, &resp) + assert.Equal(t, 201, status, "Response body: %s", string(respBody)) }) - t.Run("Invalid amount (zero)", func(t *testing.T) { - _, wrongPrivateKey, err := ed25519.GenerateKey(rand.Reader) + t.Run("Unauthorized signer returns 403", func(t *testing.T) { + signerKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) - signature := createValidSignature(wrongPrivateKey, "code") + otherKey, err := solana.NewRandomPrivateKey() + assert.NoError(t, err) + // Authorize a different key than the signer + app.config.RewardCodeAuthorizedKeys = []string{otherKey.PublicKey().String()} - requestBody := CreateRewardCodeRequest{ - Signature: signature, - Mint: "TestMint123", - Amount: 0, - } + signedTx := buildSignedMemoTx(t, signerKey, time.Now().UnixMilli()) - body, err := json.Marshal(requestBody) + body, err := json.Marshal(CreateRewardCodeRequest{ + SignedTransaction: signedTx, + Mint: "TestMint123", + Amount: 100, + }) assert.NoError(t, err) status, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ "Content-Type": "application/json", }) - - assert.Equal(t, 400, status, "Should return 400 for invalid amount") + assert.Equal(t, 403, status) }) - t.Run("Successfully creates a reward code", func(t *testing.T) { - // Generate a test keypair - testPublicKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) + t.Run("Missing signed_transaction returns 400", func(t *testing.T) { + body, err := json.Marshal(map[string]interface{}{ + "mint": "TestMint123", + "amount": 100, + }) assert.NoError(t, err) - // Convert to Solana format and inject into config - solanaPubKey := solana.PublicKeyFromBytes(testPublicKey) - testPubKeyBase58 := solanaPubKey.String() - app.config.RewardCodeAuthorizedKeys = []string{testPubKeyBase58} - - timestamp := time.Now().UnixMilli() - timestampStr := fmt.Sprintf("%d", timestamp) - signature := createValidSignature(testPrivateKey, timestampStr) + status, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ + "Content-Type": "application/json", + }) + assert.Equal(t, 400, status) + }) - requestBody := CreateRewardCodeRequest{ - Timestamp: timestamp, - Signature: signature, - Mint: "TestMint123", - Amount: 500, - } + t.Run("Invalid base64 transaction returns 400", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() + assert.NoError(t, err) + app.config.RewardCodeAuthorizedKeys = []string{privKey.PublicKey().String()} - body, err := json.Marshal(requestBody) + body, err := json.Marshal(CreateRewardCodeRequest{ + SignedTransaction: "not-valid-base64!@#$", + Mint: "TestMint123", + Amount: 100, + }) assert.NoError(t, err) - var resp CreateRewardCodeResponse - status, respBody := testPost(t, app, "/v1/rewards/code", body, map[string]string{ + status, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ "Content-Type": "application/json", - }, &resp) - assert.Equal(t, 201, status, "Response body: %s", string(respBody)) - assert.Equal(t, "TestMint123", resp.Mint) - assert.Equal(t, int64(500), resp.Amount) - assert.Len(t, resp.Code, codeLength) - assert.Regexp(t, "^[a-zA-Z0-9]{10}$", resp.Code) - - // Verify the code exists in the database and remaining_uses is 1 - var dbCode string - var dbMint string - var dbAmount int64 - var dbRemainingUses int - err = app.pool.QueryRow(context.Background(), - "SELECT code, mint, amount, remaining_uses FROM reward_codes WHERE code = $1", resp.Code). - Scan(&dbCode, &dbMint, &dbAmount, &dbRemainingUses) - assert.NoError(t, err) - assert.Equal(t, resp.Code, dbCode) - assert.Equal(t, resp.Mint, dbMint) - assert.Equal(t, resp.Amount, dbAmount) - assert.Equal(t, 1, dbRemainingUses) + }) + assert.Equal(t, 400, status) }) - t.Run("Successfully creates a reward code with second signer", func(t *testing.T) { - // Generate a test keypair - testPublicKey, _, err := ed25519.GenerateKey(rand.Reader) - testPublicKey2, testPrivateKey2, err := ed25519.GenerateKey(rand.Reader) + t.Run("Timestamp out of range returns 400", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) + app.config.RewardCodeAuthorizedKeys = []string{privKey.PublicKey().String()} - // Convert to Solana format and inject into config - solanaPubKey := solana.PublicKeyFromBytes(testPublicKey) - solanaPubKey2 := solana.PublicKeyFromBytes(testPublicKey2) - testPubKeyBase58 := solanaPubKey.String() - testPubKeyBase582 := solanaPubKey2.String() - app.config.RewardCodeAuthorizedKeys = []string{testPubKeyBase58, testPubKeyBase582} - - timestamp := time.Now().UnixMilli() - timestampStr := fmt.Sprintf("%d", timestamp) - signature2 := createValidSignature(testPrivateKey2, timestampStr) - requestBody := CreateRewardCodeRequest{ - Timestamp: timestamp, - Signature: signature2, - Mint: "TestMint123", - Amount: 500, - } + // 24 hours in the past — outside the 12h drift window + staleTimestamp := time.Now().Add(-24 * time.Hour).UnixMilli() + signedTx := buildSignedMemoTx(t, privKey, staleTimestamp) - body, err := json.Marshal(requestBody) + body, err := json.Marshal(CreateRewardCodeRequest{ + SignedTransaction: signedTx, + Mint: "TestMint123", + Amount: 100, + }) assert.NoError(t, err) - var resp CreateRewardCodeResponse - status, respBody := testPost(t, app, "/v1/rewards/code", body, map[string]string{ + status, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ "Content-Type": "application/json", - }, &resp) - assert.Equal(t, 201, status, "Response body: %s", string(respBody)) - assert.Equal(t, "TestMint123", resp.Mint) - assert.Equal(t, int64(500), resp.Amount) - assert.Len(t, resp.Code, codeLength) - assert.Regexp(t, "^[a-zA-Z0-9]{10}$", resp.Code) - - // Verify the code exists in the database and remaining_uses is 1 - var dbCode string - var dbMint string - var dbAmount int64 - var dbRemainingUses int - err = app.pool.QueryRow(context.Background(), - "SELECT code, mint, amount, remaining_uses FROM reward_codes WHERE code = $1", resp.Code). - Scan(&dbCode, &dbMint, &dbAmount, &dbRemainingUses) - assert.NoError(t, err) - assert.Equal(t, resp.Code, dbCode) - assert.Equal(t, resp.Mint, dbMint) - assert.Equal(t, resp.Amount, dbAmount) - assert.Equal(t, 1, dbRemainingUses) + }) + assert.Equal(t, 400, status) }) - t.Run("Replay prevention: same signature/timestamp with different parameters returns 400", func(t *testing.T) { - // Generate a test keypair - testPublicKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) + t.Run("Replay prevention: duplicate signature returns 400", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) + app.config.RewardCodeAuthorizedKeys = []string{privKey.PublicKey().String()} - // Convert to Solana format and inject into config - solanaPubKey := solana.PublicKeyFromBytes(testPublicKey) - testPubKeyBase58 := solanaPubKey.String() - app.config.RewardCodeAuthorizedKeys = []string{testPubKeyBase58} - - timestamp := time.Now().UnixMilli() - timestampStr := fmt.Sprintf("%d", timestamp) - signature := createValidSignature(testPrivateKey, timestampStr) - - // First request - should succeed - requestBody1 := CreateRewardCodeRequest{ - Timestamp: timestamp, - Signature: signature, - Mint: "TestMint123", - Amount: 500, - } + signedTx := buildSignedMemoTx(t, privKey, time.Now().UnixMilli()) - body1, err := json.Marshal(requestBody1) + body, err := json.Marshal(CreateRewardCodeRequest{ + SignedTransaction: signedTx, + Mint: "TestMint123", + Amount: 500, + }) assert.NoError(t, err) - var resp1 CreateRewardCodeResponse - status1, respBody1 := testPost(t, app, "/v1/rewards/code", body1, map[string]string{ + // First request succeeds + status1, respBody1 := testPost(t, app, "/v1/rewards/code", body, map[string]string{ "Content-Type": "application/json", - }, &resp1) - assert.Equal(t, 201, status1, "First request should succeed. Response body: %s", string(respBody1)) - - // Second request with same signature/timestamp but different parameters - should fail with 400 - requestBody2 := CreateRewardCodeRequest{ - Timestamp: timestamp, - Signature: signature, - Mint: "TestMint123", - Amount: 1000, // Different amount - } - - body2, err := json.Marshal(requestBody2) - assert.NoError(t, err) + }) + assert.Equal(t, 201, status1, "First request should succeed. Body: %s", string(respBody1)) - status2, respBody2 := testPost(t, app, "/v1/rewards/code", body2, map[string]string{ + // Same transaction again should be rejected as a duplicate signature + status2, _ := testPost(t, app, "/v1/rewards/code", body, map[string]string{ "Content-Type": "application/json", }) - assert.Equal(t, 400, status2, "Second request with same signature should return 400. Response body: %s", string(respBody2)) + assert.Equal(t, 400, status2) }) } @@ -278,72 +262,84 @@ func TestGenerateCode(t *testing.T) { codes[code] = true } - // With 62^6 possible combinations, we should get mostly unique codes - assert.Greater(t, len(codes), iterations*9/10, "Should unique codes") + assert.Greater(t, len(codes), iterations*9/10, "Should produce mostly unique codes") }) } -func TestVerifySignature(t *testing.T) { - t.Run("Returns false for invalid signature", func(t *testing.T) { - _, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) +func TestExtractMemoTimestamp(t *testing.T) { + t.Run("extracts timestamp from memo instruction", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) - timestamp := time.Now().UnixMilli() - timestampStr := fmt.Sprintf("%d", timestamp) - signature := createValidSignature(testPrivateKey, timestampStr) + want := int64(1747251234567) + signedTx := buildSignedMemoTx(t, privKey, want) - // Verify against a different key (should fail) - mockKey := "DDT15s6MMNxE4jkyGN46wNYqrgLWofT6WAvWtjYYrCUq" - valid, err := verifySignature(signature, timestampStr, mockKey) + tx, err := solana.TransactionFromBase64(signedTx) assert.NoError(t, err) - assert.False(t, valid, "Should return false for signature from wrong key") - }) - t.Run("Returns error for invalid base58", func(t *testing.T) { - timestamp := time.Now().UnixMilli() - timestampStr := fmt.Sprintf("%d", timestamp) - mockKey := "DDT15s6MMNxE4jkyGN46wNYqrgLWofT6WAvWtjYYrCUq" - _, err := verifySignature("not-valid-base58!@#", timestampStr, mockKey) - assert.Error(t, err, "Should return error for invalid base58") + got, err := extractMemoTimestamp(tx) + assert.NoError(t, err) + assert.Equal(t, want, got) }) - t.Run("Accepts SIMD-0048 off-chain wrapped signature (Ledger path)", func(t *testing.T) { - testPubKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) + t.Run("returns error when no memo instruction is present", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) - solanaPubKey := solana.PublicKeyFromBytes(testPubKey) - testPubKeyBase58 := solanaPubKey.String() - - timestamp := time.Now().UnixMilli() - timestampStr := fmt.Sprintf("%d", timestamp) - - for _, format := range []byte{0, 1, 2} { - wrapped := buildOffchainMessage(timestampStr, format) - signature := base58.Encode(ed25519.Sign(testPrivateKey, wrapped)) + // Build a transaction with a non-memo instruction + nonMemoIx := solana.NewInstruction( + solana.SystemProgramID, + solana.AccountMetaSlice{}, + []byte{0, 0, 0, 0}, + ) + var blockhash solana.Hash + _, err = rand.Read(blockhash[:]) + assert.NoError(t, err) + tx, err := solana.NewTransaction( + []solana.Instruction{nonMemoIx}, + blockhash, + solana.TransactionPayer(privKey.PublicKey()), + ) + assert.NoError(t, err) + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privKey.PublicKey()) { + return &privKey + } + return nil + }) + assert.NoError(t, err) - valid, err := verifySignature(signature, timestampStr, testPubKeyBase58) - assert.NoError(t, err) - assert.True(t, valid, "Should accept off-chain wrapped signature for format %d", format) - } + _, err = extractMemoTimestamp(tx) + assert.Error(t, err) }) - t.Run("Returns false for wrong message", func(t *testing.T) { - testPubKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) + t.Run("returns error when memo data is not a valid integer", func(t *testing.T) { + privKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) - // Convert test public key to Solana base58 - solanaPubKey := solana.PublicKeyFromBytes(testPubKey) - testPubKeyBase58 := solanaPubKey.String() - - timestamp1 := time.Now().UnixMilli() - timestamp2 := timestamp1 + 1000 - timestamp1Str := fmt.Sprintf("%d", timestamp1) - timestamp2Str := fmt.Sprintf("%d", timestamp2) - - // Sign one timestamp but verify against a different timestamp (should fail) - signature := createValidSignature(testPrivateKey, timestamp1Str) - valid, err := verifySignature(signature, timestamp2Str, testPubKeyBase58) + memoIx := solana.NewInstruction( + solana.MemoProgramID, + solana.AccountMetaSlice{}, + []byte("not-a-number"), + ) + var blockhash solana.Hash + _, err = rand.Read(blockhash[:]) + assert.NoError(t, err) + tx, err := solana.NewTransaction( + []solana.Instruction{memoIx}, + blockhash, + solana.TransactionPayer(privKey.PublicKey()), + ) assert.NoError(t, err) - assert.False(t, valid, "Should return false for signature of wrong message") + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privKey.PublicKey()) { + return &privKey + } + return nil + }) + assert.NoError(t, err) + + _, err = extractMemoTimestamp(tx) + assert.Error(t, err) }) }