packages feed

solana-haskell-sdk-1.4.0.0: src/Network/Solana/Core/MessageV1.hs

{-# LANGUAGE RecordWildCards #-}

-- |
-- Module      : Network.Solana.Core.MessageV1
-- Description : v1 message compilation (SIMD-0385) and v1 transaction signing.
--
-- The v1 transaction format carries its resource requests in a header
-- configuration mask instead of @ComputeBudget@ instructions, lists every
-- address inline (it has no address lookup tables), and places the
-- signatures /after/ the message rather than before it. See
-- "Network.Solana.Core.VersionedMessage" for v0, which is the format to use
-- when lookup tables are needed.
--
-- Unlike the legacy and v0 builders, both entry points here take the fee
-- payer explicitly. The reference implementation's only v1 compile path
-- does the same (@v1::Message::try_compile_with_config(payer, ..)@), and
-- deriving the payer implicitly from the instruction list is a trap this
-- format makes expensive: the priority fee travels inside the message, so
-- whoever lands in account 0 pays it.
module Network.Solana.Core.MessageV1
  ( TransactionConfig (..),
    emptyTransactionConfig,
    compileV1Message,
    newV1TransactionIntent,
  )
where

import Data.Binary (Word16, Word32, Word64, Word8, put)
import Data.Binary.Put
import Data.Bits ((.|.))
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import GHC.Generics (Generic)
import Network.Solana.Core.Block (BlockHash)
import Network.Solana.Core.Compact (unCompact)
import Network.Solana.Core.Crypto
  ( SolanaPrivateKey,
    SolanaPublicKey,
    dsign,
    getSolanaSignatureRaw,
    toBase64String,
  )
import Network.Solana.Core.Instruction
import Network.Solana.Core.Message

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

-- * TransactionConfig

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

-- | The resource requests a v1 message carries in place of the
-- @ComputeBudget@ instructions earlier formats use. Each field present
-- sets its bit in the message's configuration mask and appends its value.
--
-- __An absent field does not mean \"the usual default\"__: SIMD-0385 uses
-- the /minimum/ value instead, which for two of these fields is zero. A
-- transaction requesting zero compute units, or zero bytes of loaded
-- account data, is still accepted into a block and still charges its fee
-- payer -- it simply fails without doing anything. 'compileV1Message'
-- therefore refuses to build one; see 'emptyTransactionConfig'.
--
-- @ComputeBudget@ instructions placed in a v1 transaction are ignored for
-- configuration purposes -- they still execute, as successful no-ops that
-- consume compute units -- so resource requests must be made here.
data TransactionConfig = TransactionConfig
  { -- | Total priority fee, in lamports; absent means 0.
    --
    -- __Not the same unit as @ComputeBudget@__: @SetComputeUnitPrice@ takes
    -- micro-lamports /per compute unit/, while this is a flat total. A
    -- value carried across unconverted overpays by a factor of up to a
    -- million, and the network accepts it. To port one, multiply the price
    -- by the compute-unit limit and divide by 1e6.
    tcPriorityFee :: Maybe Word64,
    -- | Maximum compute units the transaction may consume. Absent means 0,
    -- which cannot execute anything, so this is effectively required.
    tcComputeUnitLimit :: Maybe Word32,
    -- | Maximum bytes of account data the transaction may load. Absent
    -- means 0 -- not the 64 MiB earlier formats default to -- so this is
    -- effectively required too.
    tcLoadedAccountsDataSizeLimit :: Maybe Word32,
    -- | Heap size in bytes; absent means 32 KiB. When given it must be a
    -- multiple of 1024 and within [32768, 262144]; 'compileV1Message'
    -- rejects anything else.
    tcHeapSize :: Maybe Word32
  }
  deriving (Show, Eq, Generic)

-- | A configuration requesting nothing at all.
--
-- This is a base for record update, not a usable configuration:
-- 'compileV1Message' rejects it, because a v1 message with no compute-unit
-- limit and no loaded-accounts limit requests zero of both and so is
-- guaranteed to be charged for and then fail (see 'TransactionConfig').
-- Fill in at least those two fields:
--
-- > emptyTransactionConfig
-- >   { tcComputeUnitLimit = Just 200000,
-- >     tcLoadedAccountsDataSizeLimit = Just 131072
-- >   }
emptyTransactionConfig :: TransactionConfig
emptyTransactionConfig =
  TransactionConfig
    { tcPriorityFee = Nothing,
      tcComputeUnitLimit = Nothing,
      tcLoadedAccountsDataSizeLimit = Nothing,
      tcHeapSize = Nothing
    }

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

-- * Compile v1 message

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

-- | Compile a v1 message for the given fee payer: assemble the account keys
-- exactly as the legacy and v0 builders do (privilege union, then canonical
-- ordering, via 'mkNewMessageWithPayer', which seeds the payer as a writable
-- signer and pins it to account 0), then serialize them in the v1 layout
-- behind the @129@ version byte. Naming the payer also supports sponsored
-- fees: it need not appear in any instruction.
--
-- Fails with a 'CompileException' when the result would not be a usable v1
-- message:
--
-- * 'LimitExceeded' -- more than 64 addresses, more than 64 instructions,
--   more than 12 required signatures, an instruction with more than 255
--   accounts or more than 65535 data bytes, or a transaction that would
--   exceed 4096 bytes once signed.
-- * 'InvalidMessage' -- no instructions at all, the fee payer used as a
--   program id, a compute-unit or loaded-accounts request of zero (see
--   'TransactionConfig'), or a heap request that is not a multiple of 1024
--   within [32768, 262144].
compileV1Message ::
  SolanaPublicKey -> BlockHash -> [Instruction] -> TransactionConfig -> Either CompileException BS.ByteString
compileV1Message payer bh instructions =
  compileV1FromLegacy (mkNewMessageWithPayer payer bh instructions)

-- | Build and sign a v1 transaction for the given fee payer, returning it
-- Base64-encoded. A v1 transaction puts the message first and the
-- signatures last, as a bare sequence of 64-byte signatures with no length
-- prefix -- the reverse of the legacy and v0 envelopes, and the reason the
-- signature count has to match the message's required signers exactly:
-- there is no count on the wire to describe a mismatch.
--
-- The given private keys may be listed in any order. They are matched
-- against the compiled message's required signers and placed in message
-- order automatically, so a key for the wrong account, a missing key or a
-- spare key is a 'MissingIndex' failure rather than a silently malformed
-- transaction.
newV1TransactionIntent ::
  SolanaPublicKey ->
  [SolanaPrivateKey] ->
  [Instruction] ->
  TransactionConfig ->
  BlockHash ->
  Either CompileException String
newV1TransactionIntent payer signingKeys instructions config bh = do
  let legacy = mkNewMessageWithPayer payer bh instructions
  msg <- compileV1FromLegacy legacy config
  orderedKeys <- orderSigningKeys legacy signingKeys
  return $ toBase64String (msg <> signaturesOver msg orderedKeys)

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

-- * Internal helpers

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

-- | Serialize an assembled legacy message in the v1 layout.
--
-- The header's @numReadonlySignedAccounts < numRequiredSignatures@ sanitize
-- rule needs no check here: every caller routes through
-- 'mkNewMessageWithPayer', which puts the payer in the writable-signer
-- section and never demotes it, so that section is never empty.
compileV1FromLegacy :: Message -> TransactionConfig -> Either CompileException BS.ByteString
compileV1FromLegacy legacy config = do
  compiled <- mapM (compileInstruction keys) (mInstructions legacy)
  checkLimits legacy compiled config
  let bytes = BL.toStrict . runPut $ do
        putWord8 v1Prefix
        put (mHeader legacy)
        putWord32le (configMask config)
        put (mRecentBlockhash legacy)
        putWord8 (fromIntegral (length compiled))
        putWord8 (fromIntegral (length keys))
        mapM_ put keys
        putConfigValues config
        mapM_ putInstructionHeader compiled
        mapM_ putInstructionPayload compiled
  checkTransactionSize legacy bytes
  return bytes
  where
    keys = mAccountKeys legacy

-- | The trailing signature array: each signer's 64 raw signature bytes over
-- the whole serialized message, concatenated without a length prefix.
signaturesOver :: BS.ByteString -> [SolanaPrivateKey] -> BS.ByteString
signaturesOver msg signers = mconcat (getSolanaSignatureRaw . flip dsign msg <$> signers)

-- | The version byte a v1 transaction opens with.
v1Prefix :: Word8
v1Prefix = 129

-- | The configuration mask: one bit per requested field, except the
-- priority fee, which occupies two (both must be set, and its value is
-- eight bytes wide rather than four).
--
-- The compute-unit and loaded-accounts bits are only distinguishable from
-- each other when exactly one of the two is present, which 'checkLimits'
-- forbids -- so no test can pin them apart while that guard stands. Relax
-- the guard and they need pinning.
configMask :: TransactionConfig -> Word32
configMask TransactionConfig {..} =
  bitFor tcPriorityFee 0b11
    .|. bitFor tcComputeUnitLimit 0b100
    .|. bitFor tcLoadedAccountsDataSizeLimit 0b1000
    .|. bitFor tcHeapSize 0b10000
  where
    bitFor field bits = maybe 0 (const bits) field

-- | The requested values, in field order, each present only when its mask
-- bit is set.
putConfigValues :: TransactionConfig -> Put
putConfigValues TransactionConfig {..} = do
  mapM_ putWord64le tcPriorityFee
  mapM_ putWord32le tcComputeUnitLimit
  mapM_ putWord32le tcLoadedAccountsDataSizeLimit
  mapM_ putWord32le tcHeapSize

-- | One instruction's fixed-width header: program id index, account count,
-- and data length. All headers precede all payloads on the wire.
putInstructionHeader :: CompiledInstruction -> Put
putInstructionHeader CompiledInstruction {..} = do
  putWord8 ciProgramIdIndex
  putWord8 (fromIntegral (length (unCompact ciAccounts)))
  putWord16le (fromIntegral (length (unCompact ciData)))

-- | One instruction's variable-width payload: its account indices followed
-- by its data bytes, neither length-prefixed (the header carries both
-- lengths).
putInstructionPayload :: CompiledInstruction -> Put
putInstructionPayload CompiledInstruction {..} = do
  mapM_ putWord8 (unCompact ciAccounts)
  mapM_ putWord8 (unCompact ciData)

-- | Everything the builder can check before serializing: the format's count
-- caps, the sanitize rules reachable from here, and the resource requests
-- that would make the transaction pay to do nothing.
checkLimits :: Message -> [CompiledInstruction] -> TransactionConfig -> Either CompileException ()
checkLimits legacy compiled TransactionConfig {..} = do
  limit "addresses" maxAddresses (length (mAccountKeys legacy))
  limit "instructions" maxInstructions (length compiled)
  limit "required signatures" maxSignatures (fromIntegral (numRequiredSignatures (mHeader legacy)))
  checkNonEmpty
  mapM_ checkInstruction compiled
  checkRequest "compute-unit limit" tcComputeUnitLimit
  checkRequest "loaded-accounts data size limit" tcLoadedAccountsDataSizeLimit
  mapM_ checkHeapSize tcHeapSize
  where
    limit what cap actual
      | actual <= cap = Right ()
      | otherwise =
          Left
            ( LimitExceeded
                ("v1 message has " <> show actual <> " " <> what <> ", more than the " <> show cap <> " allowed")
            )
    checkNonEmpty
      | null compiled = Left (InvalidMessage "v1 message has no instructions, so it would pay a fee to do nothing")
      | otherwise = Right ()
    checkInstruction CompiledInstruction {..} = do
      limit "accounts in one instruction" maxInstructionAccounts (length (unCompact ciAccounts))
      limit "data bytes in one instruction" maxInstructionData (length (unCompact ciData))
      if ciProgramIdIndex == 0
        then Left (InvalidMessage "v1 instruction invokes account 0, but the fee payer cannot be a program id")
        else Right ()
    -- Absent or zero are the same request on the wire, and both guarantee
    -- an included, charged, failing transaction -- so both are refused.
    checkRequest what request
      | request `elem` [Nothing, Just 0] =
          Left
            ( InvalidMessage
                ( "v1 message requests a "
                    <> what
                    <> " of 0, which cannot execute anything; set it in the TransactionConfig"
                )
            )
      | otherwise = Right ()
    checkHeapSize n
      | n `mod` 1024 /= 0 =
          Left (InvalidMessage ("v1 heap size " <> show n <> " is not a multiple of 1024"))
      | n < minHeapSize =
          Left (InvalidMessage ("v1 heap size " <> show n <> " is below the minimum of " <> show minHeapSize))
      | n > maxHeapSize =
          Left (InvalidMessage ("v1 heap size " <> show n <> " is above the maximum of " <> show maxHeapSize))
      | otherwise = Right ()

-- | Reject a message whose transaction -- the message plus one 64-byte
-- signature per required signer -- would exceed the v1 size cap. Checked on
-- the message, since the header fixes the signature count and
-- 'newV1TransactionIntent' signs exactly that many.
checkTransactionSize :: Message -> BS.ByteString -> Either CompileException ()
checkTransactionSize legacy bytes
  | total <= maxTransactionSize = Right ()
  | otherwise =
      Left
        ( LimitExceeded
            ( "v1 transaction is "
                <> show total
                <> " bytes, more than the "
                <> show maxTransactionSize
                <> " allowed"
            )
        )
  where
    signatures = signatureSize * fromIntegral (numRequiredSignatures (mHeader legacy))
    total = BS.length bytes + signatures

-- | Addresses are counted by a single byte and capped at 64 by SIMD-0385.
maxAddresses :: Int
maxAddresses = 64

-- | Instructions are counted by a single byte and capped at 64.
maxInstructions :: Int
maxInstructions = 64

-- | A v1 transaction may carry at most 12 signatures.
maxSignatures :: Int
maxSignatures = 12

-- | An instruction's account count is a single byte.
maxInstructionAccounts :: Int
maxInstructionAccounts = fromIntegral (maxBound :: Word8)

-- | An instruction's data length is a 'Word16'.
maxInstructionData :: Int
maxInstructionData = fromIntegral (maxBound :: Word16)

-- | A v1 transaction may be at most 4096 bytes on the wire (SIMD-0296),
-- against the 1232 of earlier formats.
maxTransactionSize :: Int
maxTransactionSize = 4096

-- | One ed25519 signature.
signatureSize :: Int
signatureSize = 64

minHeapSize :: Word32
minHeapSize = 32 * 1024

maxHeapSize :: Word32
maxHeapSize = 256 * 1024