packages feed

solana-haskell-sdk-1.2.0.0: src/Network/Solana/Core/Message.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- Module      : Network.Solana.Core.Message
-- Description : Building, canonically ordering, compiling and signing transaction messages.
module Network.Solana.Core.Message
  ( newTransactionIntent,
    newTransactionIntentWithPayer,
    newDurableNonceTransactionIntent,
    newDurableNonceTransactionIntentWithPayer,
    SignedTransactionIntent,
    Message (..),
    MessageHeader (..),
    CompiledMessage,
    newMessage,
    newMessageToBase64String,
    mkNewMessage,
  )
where

import Data.Aeson
import Data.Aeson.Types (Parser)
import Data.Binary
import Data.ByteString qualified as S
import Data.Foldable
import Data.List (sortOn)
import GHC.Generics (Generic)
import Network.Solana.Core.Block (BlockHash)
import Network.Solana.Core.Compact
import Network.Solana.Core.Crypto (SolanaPrivateKey, SolanaPublicKey, dsign, getSolanaPublicKeyRaw, toBase64String, toSolanaPublicKey)
import Network.Solana.Core.Instruction
import Network.Solana.NativePrograms.SystemProgram qualified as SystemProgram


------------------------------------------------------------------------------------------------

-- * SignedTransactionIntent

------------------------------------------------------------------------------------------------

-- | A transaction awaiting a recent block hash: applying one yields the
-- Base64-encoded signed transaction, or a 'CompileException'.
type SignedTransactionIntent = (BlockHash -> Either CompileException String)


-- | Builds and signs a transaction from a list of signing keys and
-- instructions. The fee payer is the first writable signer encountered
-- across the instructions' account metas, and account keys are canonically
-- ordered (fee payer first, then each privilege section sorted by pubkey
-- bytes; see @canonicalizeAccountOrder@). The private keys passed here must
-- be ordered to match the resulting account order (fee payer first),
-- otherwise the produced signatures will not correspond to the right
-- account keys. If you would rather name the fee payer explicitly and not
-- worry about signer order, consider 'newTransactionIntentWithPayer',
-- which orders signatures automatically.
newTransactionIntent :: [SolanaPrivateKey] -> [Instruction] -> SignedTransactionIntent
newTransactionIntent signers instructions blockhash = do
  msg <- newMessage blockhash instructions -- make the binary message
  let signatures = S.toStrict . Data.Binary.encode $ mkCompact $ flip dsign msg <$> signers -- sign the binary message
  return $ toBase64String $ S.append signatures msg -- return signed transaction

-- | Builds and signs a transaction with an explicitly named fee payer.
-- Unlike 'newTransactionIntent', the fee payer is always account 0
-- (sponsored fees are supported: the payer need not appear in any
-- instruction at all), and the given private keys may be listed in any
-- order — they are matched against the compiled message's required
-- signers and the resulting signatures are placed in message order
-- automatically. 'Left' if a required signer has no corresponding private
-- key, or if a given private key does not correspond to any required
-- signer. Duplicate private keys for the same required signer are
-- tolerated: the first match signs, mirroring the Rust SDK's @try_sign@.
newTransactionIntentWithPayer :: SolanaPublicKey -> [SolanaPrivateKey] -> [Instruction] -> BlockHash -> Either CompileException String
newTransactionIntentWithPayer payer signingKeys instructions blockhash = do
  let msg = mkNewMessageWithPayer payer blockhash instructions
  msgBytes <- compileMessageToBinary msg
  let requiredSigners = take (fromIntegral (numRequiredSignatures (mHeader msg))) (mAccountKeys msg)
      keyedByPubkey = [(toSolanaPublicKey k, k) | k <- signingKeys]
  orderedKeys <- mapM (findSigningKey keyedByPubkey) requiredSigners
  mapM_ (checkKeyIsRequired requiredSigners . fst) keyedByPubkey
  let signatures = S.toStrict . Data.Binary.encode $ mkCompact $ flip dsign msgBytes <$> orderedKeys
  return $ toBase64String $ S.append signatures msgBytes
  where
    findSigningKey keyedByPubkey pk =
      case lookup pk keyedByPubkey of
        Just k -> Right k
        Nothing -> Left (MissingIndex ("missing signer for " <> show pk))
    checkKeyIsRequired requiredSigners pk =
      if pk `elem` requiredSigners
        then Right ()
        else Left (MissingIndex ("unused signing key " <> show pk))

-- | Builds and signs a durable-nonce transaction: prepends
-- @SystemProgram.advanceNonceAccount nonceAccount nonceAuthority@ ahead of
-- the given instructions and delegates to 'newTransactionIntent'. The
-- durable nonce — read from the nonce account's on-chain 'Network.Solana.NativePrograms.SystemProgram.NonceState' via
-- @nsDurableNonce@ — is passed in place of a recent block hash, since a
-- durable-nonce transaction never expires and does not depend on a recent
-- ledger entry. The nonce authority must be among the given signers, or the
-- resulting transaction will be rejected on submission.
--
-- ORDER-SENSITIVE: as with 'newTransactionIntent', the given private keys
-- must be ordered to match the resulting canonical account order (fee payer
-- first), or the produced signatures will not correspond to the right
-- account keys. If you would rather name the fee payer explicitly and not
-- worry about signer order, use 'newDurableNonceTransactionIntentWithPayer'
-- instead.
newDurableNonceTransactionIntent :: [SolanaPrivateKey] -> SolanaPublicKey -> SolanaPublicKey -> [Instruction] -> BlockHash -> Either CompileException String
newDurableNonceTransactionIntent signers nonceAccount nonceAuthority instructions =
  newTransactionIntent signers (SystemProgram.advanceNonceAccount nonceAccount nonceAuthority : instructions)

-- | Builds and signs a durable-nonce transaction with an explicitly named
-- fee payer: prepends @SystemProgram.advanceNonceAccount nonceAccount
-- nonceAuthority@ ahead of the given instructions and delegates to
-- 'newTransactionIntentWithPayer', composing sponsored fees with durable
-- nonces. As with 'newTransactionIntentWithPayer', the fee payer is forced
-- into account 0 (even if it appears in no instruction), and the given
-- private keys may be listed in any order — signatures are auto-ordered to
-- match the compiled message. The nonce authority must be among the given
-- signers, or the resulting transaction will be rejected on submission.
newDurableNonceTransactionIntentWithPayer :: SolanaPublicKey -> [SolanaPrivateKey] -> SolanaPublicKey -> SolanaPublicKey -> [Instruction] -> BlockHash -> Either CompileException String
newDurableNonceTransactionIntentWithPayer payer signers nonceAccount nonceAuthority instructions =
  newTransactionIntentWithPayer payer signers (SystemProgram.advanceNonceAccount nonceAccount nonceAuthority : instructions)

------------------------------------------------------------------------------------------------

-- ** Message

------------------------------------------------------------------------------------------------

-- | The structure of a transaction message.
data Message = Message
  { -- | Specifies the number of signer and read-only account.
    mHeader :: MessageHeader,
    -- |  All the account keys used by this transaction (used by all the instructions on the transaction).
    mAccountKeys :: [SolanaPublicKey],
    -- | The id of a recent ledger entry. Acts as a timestamp for the transaction.
    mRecentBlockhash :: BlockHash,
    {-  An array of instructions to be executed.
        Programs that will be executed in sequence and committed in one atomic transaction if all succeed.
    -}
    mInstructions :: [Instruction]
  }
  deriving (Show, Eq, Generic)

-- | Compile instructions into serialized legacy-message bytes using the
-- given recent block hash. Account keys are collected and canonically
-- ordered as described at 'newTransactionIntent'.
newMessage :: BlockHash -> [Instruction] -> Either CompileException S.ByteString
newMessage bh is = compileMessageToBinary (mkNewMessage bh is)

-- | Like 'newMessage', but returns the message bytes Base64-encoded.
newMessageToBase64String :: BlockHash -> [Instruction] -> Either CompileException String
newMessageToBase64String = fmap (fmap toBase64String) . newMessage

compileMessageToBinary :: Message -> Either CompileException S.ByteString
compileMessageToBinary = fmap (S.toStrict . Data.Binary.encode) . compileMessage

-- | Assemble the uncompiled 'Message': fold the instructions' account
-- metas into a deduplicated, privilege-merged key list, then put it in
-- canonical order.
mkNewMessage :: BlockHash -> [Instruction] -> Message
mkNewMessage bh =
  canonicalizeAccountOrder . updateMessageWithInstructions (Message mempty mempty bh mempty)

-- | Like 'mkNewMessage', but with an explicitly named fee payer. The payer
-- is seeded as a writable signer before the instructions' account metas are
-- folded in, so it ends up in the message (and pinned first by
-- 'canonicalizeAccountOrder') even if no instruction references it —
-- enabling sponsored-fee transactions.
mkNewMessageWithPayer :: SolanaPublicKey -> BlockHash -> [Instruction] -> Message
mkNewMessageWithPayer payer bh =
  canonicalizeAccountOrder . updateMessageWithInstructions seeded
  where
    payerMeta = AccountMeta {accountPubKey = payer, isSigner = True, isWritable = True}
    (seededHeader, seededKeys) = updateHeaderAndKeys (mempty, mempty) payerMeta
    seeded = Message seededHeader seededKeys bh mempty

-- | Sort the account keys within each privilege section by raw pubkey bytes,
-- keeping the fee payer (first writable signer) pinned in first position.
-- Matches the Rust SDK's CompiledKeys (BTreeMap) ordering, which is required
-- for byte-identical messages once several program ids share a section.
canonicalizeAccountOrder :: Message -> Message
canonicalizeAccountOrder (Message header keys bh instrs) =
  let (rws, ros, rwus, rous) = splitAccountsByPurpose header keys
      sortKeys = sortOn getSolanaPublicKeyRaw
      rws' = case rws of
        [] -> []
        (feePayer : rest) -> feePayer : sortKeys rest
      keys' = rws' <> sortKeys ros <> sortKeys rwus <> sortKeys rous
   in Message header keys' bh instrs

updateMessageWithInstructions :: Message -> [Instruction] -> Message
updateMessageWithInstructions = foldl' updateMessageWithInstruction

updateMessageWithInstruction :: Message -> Instruction -> Message
updateMessageWithInstruction (Message header accountKeys bh instrs) newInstr =
  let programMeta =
        AccountMeta
          { accountPubKey = iProgramId newInstr,
            isSigner = False,
            isWritable = False
          }
      (newHeader, newAccountKeys) =
        foldl' updateHeaderAndKeys (header, accountKeys) (iAccounts newInstr <> [programMeta])
      newInstrucionsList = instrs <> [newInstr]
   in Message newHeader newAccountKeys bh newInstrucionsList

updateHeaderAndKeys :: (MessageHeader, [SolanaPublicKey]) -> AccountMeta -> (MessageHeader, [SolanaPublicKey])
updateHeaderAndKeys (currentHeader, currentKeys) (AccountMeta newKey isSigner isWritable) =
  let (rws, ros, rwus, rous) = splitAccountsByPurpose currentHeader currentKeys
      alreadySignable = newKey `elem` rws <> ros
      alreadyWritable = newKey `elem` rws <> rwus
      (rws', ros', rwus', rous') =
        case (isSigner || alreadySignable, isWritable || alreadyWritable) of
          (True, True) ->
            ( addIfNotExists newKey rws,
              removeAll newKey ros,
              removeAll newKey rwus,
              removeAll newKey rous
            )
          (True, False) ->
            ( rws,
              addIfNotExists newKey ros,
              rwus,
              removeAll newKey rous
            )
          (False, True) ->
            ( rws,
              ros,
              addIfNotExists newKey rwus,
              removeAll newKey rous
            )
          (False, False) ->
            ( rws,
              ros,
              rwus,
              addIfNotExists newKey rous
            )
      updatedMessage = mkMessageHeaderFromSplittedAccounts (rws', ros', rwus', rous')
      updatedKeys = (rws' <> ros' <> rwus' <> rous')
   in (updatedMessage, updatedKeys)
  where
    addIfNotExists :: (Eq a) => a -> [a] -> [a]
    addIfNotExists x xs =
      if x `elem` xs
        then xs
        else xs ++ [x]

    removeAll :: (Eq a) => a -> [a] -> [a]
    removeAll x = filter (/= x)

splitAccountsByPurpose ::
  MessageHeader ->
  [SolanaPublicKey] ->
  ([SolanaPublicKey], [SolanaPublicKey], [SolanaPublicKey], [SolanaPublicKey])
splitAccountsByPurpose
  (MessageHeader x y z)
  keys =
    let numRequiredSignatures = fromIntegral x
        numReadonlySigned = fromIntegral y
        numReadonlyUnsigned = fromIntegral z

        -- Split into signed and unsigned keys:
        (signed, unsigned) = splitAt numRequiredSignatures keys
        -- For signed keys: first part are read and write, last part are read-only.
        (readAndWriteSigned, readOnlySigned) = splitAt (numRequiredSignatures - numReadonlySigned) signed
        -- For unsigned keys:
        unsignedCount = length unsigned
        (readAndWriteUnsigned, readOnlyUnsigned) = splitAt (unsignedCount - numReadonlyUnsigned) unsigned
     in (readAndWriteSigned, readOnlySigned, readAndWriteUnsigned, readOnlyUnsigned)

mkMessageHeaderFromSplittedAccounts ::
  ([SolanaPublicKey], [SolanaPublicKey], [SolanaPublicKey], [SolanaPublicKey]) ->
  MessageHeader
mkMessageHeaderFromSplittedAccounts (readAndWriteSigned, readOnlySigned, _readAndWriteUnsigned, readOnlyUnsigned) =
  let numRequiredSignatures = fromIntegral $ length readAndWriteSigned + length readOnlySigned
      numReadonlySigned = fromIntegral $ length readOnlySigned
      numReadonlyUnsigned = fromIntegral $ length readOnlyUnsigned
   in MessageHeader numRequiredSignatures numReadonlySigned numReadonlyUnsigned

------------------------------------------------------------------------------------------------

-- *** MessageHeader

------------------------------------------------------------------------------------------------
-- | Three bytes at the front of every message declaring account privileges.
data MessageHeader = MessageHeader
  { -- | Number of signatures required for the message to be valid; the
    -- first @numRequiredSignatures@ account keys of the message must sign,
    -- in order.
    numRequiredSignatures :: Word8,
    -- | Of the signed keys, the last @numReadonlySignedAccounts@ are
    -- read-only.
    numReadonlySignedAccounts :: Word8,
    -- | Of the unsigned keys, the last @numReadonlyUnsignedAccounts@ are
    -- read-only.
    numReadonlyUnsignedAccounts :: Word8
  }
  deriving (Show, Eq, Generic)
  deriving anyclass (ToJSON, FromJSON)

instance Binary MessageHeader where
  put :: MessageHeader -> Put
  put MessageHeader {..} = do
    put numRequiredSignatures
    put numReadonlySignedAccounts
    put numReadonlyUnsignedAccounts

  get :: Get MessageHeader
  get = MessageHeader <$> get <*> get <*> get

instance Semigroup MessageHeader where
  (<>) :: MessageHeader -> MessageHeader -> MessageHeader
  (<>) (MessageHeader rqs ros rou) (MessageHeader rqs' ros' rou') = MessageHeader (rqs + rqs') (ros + ros') (rou + rou')

instance Monoid MessageHeader where
  mempty :: MessageHeader
  mempty = MessageHeader 0 0 0

------------------------------------------------------------------------------------------------

-- ** Compiled Message

------------------------------------------------------------------------------------------------

-- | The structure of a Compiled Message.
data CompiledMessage = CompiledMessage
  { -- | Specifies the number of signer and read-only account.
    cmHeader :: MessageHeader,
    -- |  Compact array of account keys used by this transaction (used by all the instructions on the transaction).
    cmAccountKeys :: CompactArray SolanaPublicKey,
    -- | The id of a recent ledger entry. Acts as a timestamp for the transaction.
    cmRecentBlockhash :: BlockHash,
    {-  Compact array of compiled instructions to be executed.
    -}
    cmInstructions :: CompactArray CompiledInstruction
  }
  deriving (Show, Eq, Generic)

instance ToJSON CompiledMessage where
  toJSON :: CompiledMessage -> Value
  toJSON (CompiledMessage header accountKeys recentBlockhash instructions) =
    object
      [ "header" .= header,
        "accountKeys" .= unCompact accountKeys,
        "recentBlockhash" .= recentBlockhash,
        "instructions" .= unCompact instructions
      ]

instance FromJSON CompiledMessage where
  parseJSON :: Value -> Parser CompiledMessage
  parseJSON = withObject "CompiledMessage" $ \v ->
    CompiledMessage
      <$> v .: "header"
      <*> (mkCompact <$> (v .: "accountKeys"))
      <*> v .: "recentBlockhash"
      <*> (mkCompact <$> (v .: "instructions"))

instance Binary CompiledMessage where
  put :: CompiledMessage -> Put
  put CompiledMessage {..} = do
    put cmHeader
    put cmAccountKeys
    put cmRecentBlockhash
    put cmInstructions

------------------------------------------------------------------------------------------------

-- *** Compile Message

------------------------------------------------------------------------------------------------
compileMessage :: Message -> Either CompileException CompiledMessage
compileMessage Message {..} = do
  compiledInstrctions <- mapM (compileInstruction mAccountKeys) mInstructions
  return $
    CompiledMessage
      { cmHeader = mHeader,
        cmAccountKeys = mkCompact mAccountKeys,
        cmRecentBlockhash = mRecentBlockhash,
        cmInstructions = mkCompact compiledInstrctions
      }