From 435c33a255da2f982c5666561b7f8fbfd4414356 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Thu, 14 May 2026 20:26:19 -0700 Subject: [PATCH] fix(rewards): accept SIMD-0048 off-chain wrapped signatures Hardware wallets (e.g. Ledger via Phantom) refuse to sign arbitrary bytes and instead sign the SIMD-0048 off-chain message envelope (\xffsolana offchain | version | format | len | message). This caused authorized Ledger users to receive 403 Unauthorized when creating reward codes, since verification was only attempted against the raw message bytes. verifySignature now tries the raw message first (hot-wallet path) and falls back to the wrapped envelope across all three message formats. Co-Authored-By: Claude Opus 4.7 --- api/v1_create_reward_code.go | 37 +++++++++++++++++++++++++++---- api/v1_create_reward_code_test.go | 20 +++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index 1c5b509c..60572f99 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -64,6 +64,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) @@ -77,10 +96,20 @@ func verifySignature(signatureBase58 string, message string, authorizedPubKey st return false, err } - // Verify the signature - messageBytes := []byte(message) - valid := ed25519.Verify(expectedPubKey[:], messageBytes, signatureBytes) - return valid, nil + // 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 + } + } + + return false, nil } func verifySignatureAgainstKeys(signatureBase58 string, message string, authorizedKeys []string) (string, error) { diff --git a/api/v1_create_reward_code_test.go b/api/v1_create_reward_code_test.go index a11bb09a..947bf441 100644 --- a/api/v1_create_reward_code_test.go +++ b/api/v1_create_reward_code_test.go @@ -307,6 +307,26 @@ func TestVerifySignature(t *testing.T) { assert.Error(t, err, "Should return error for invalid base58") }) + t.Run("Accepts SIMD-0048 off-chain wrapped signature (Ledger path)", func(t *testing.T) { + testPubKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) + 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)) + + valid, err := verifySignature(signature, timestampStr, testPubKeyBase58) + assert.NoError(t, err) + assert.True(t, valid, "Should accept off-chain wrapped signature for format %d", format) + } + }) + t.Run("Returns false for wrong message", func(t *testing.T) { testPubKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) assert.NoError(t, err)