solana-haskell-sdk-1.3.0.0: test-integration/Test/Integration/Transfer.hs
-- | Live SOL transfer coverage: a plain transfer from a freshly airdropped
-- payer, a sponsored transfer where the fee payer and the transfer's signer
-- are different accounts, and the fee-payer contract of the public
-- 'newTransaction' helper (first key pays, read-only-signer-only
-- instructions land, empty signer list rejected).
module Test.Integration.Transfer (tests) where
import Control.Exception (SomeException, throwIO, try)
import Control.Monad (void)
import Control.Monad.IO.Class (liftIO)
import Data.List (isInfixOf)
import Network.Solana.Core.Crypto (createSolanaKeyPair)
import Network.Solana.Core.Message (newTransactionIntentWithPayer)
import Network.Solana.NativePrograms.SystemProgram qualified as SystemProgram
import Network.Solana.RPC.HTTP.Account (getBalance)
import Network.Solana.RPC.HTTP.Block (getTheLatestBlockhash)
import Network.Solana.RPC.HTTP.Chain (getVersion)
import Network.Solana.RPC.HTTP.Transaction (sendTransaction)
import Network.Solana.SolanaWeb3 (newTransaction)
import Network.Solana.SplPrograms.Memo qualified as Memo
import Network.Web3.Provider (Web3)
import Test.Integration.Setup
import Test.Tasty
import Test.Tasty.HUnit
tests :: TestTree
tests =
testGroup
"Transfer"
[ testCase "getVersion answers" (run (void getVersion)),
testCase "airdropped SOL transfer lands with the exact amount" (run basicTransfer),
testCase "sponsored transfer: sponsor pays the fee, signer order is free" (run sponsoredTransfer),
testCase "newTransaction: memo-only instruction (read-only authority signer) lands" (run memoOnly),
testCase "newTransaction: empty signer list is rejected before any RPC call" noSigners,
testCase "newTransaction: first key pays even when unreferenced, signer order is free" (run firstKeyPays)
]
-- | Airdrops a payer, sends it through 'newTransaction' (which builds its
-- own blockhash and names its first key as the fee payer), and checks the
-- recipient receives exactly the transferred amount. Live-covers
-- 'newTransaction' with every dependency (airdrop, blockhash, confirmation)
-- finalized.
basicTransfer :: Web3 ()
basicTransfer = do
(payerPk, payerSk) <- fundedKeypair 2_000_000_000
recipient <- fst <$> liftIO createSolanaKeyPair
sig <- newTransaction [payerSk] [SystemProgram.transfer payerPk recipient 1_000_000_000]
confirmFinalized sig
bal <- getBalance recipient
liftIO (bal @?= 1_000_000_000)
-- The README's sequence: balances read right after confirmFinalized reflect
-- the transfer on both sides.
senderBal <- getBalance payerPk
liftIO (assertBool "sender paid the transfer and the fee" (senderBal < 1_000_000_000))
-- | A sponsor pays the fee for a transfer sent by another funded account, with
-- the private keys deliberately passed in the wrong order to
-- 'newTransactionIntentWithPayer' -- its auto-ordering must still produce a
-- valid transaction. Checks that the recipient and sender balances reflect
-- only the transfer amount (the sender pays no fee) and that the sponsor's
-- balance dropped by more than the transfer (it paid the fee).
sponsoredTransfer :: Web3 ()
sponsoredTransfer = do
pairs <- fundedKeypairs [2_000_000_000, 1_000_000_000]
((sponsorPk, sponsorSk), (senderPk, senderSk)) <- case pairs of
[a, b] -> pure (a, b)
_ -> liftIO (throwIO (userError "expected two keypairs"))
recipient <- fst <$> liftIO createSolanaKeyPair
bh <- getTheLatestBlockhash
-- signers deliberately in the WRONG order: auto-ordering must fix it
tx <-
either
(liftIO . throwIO)
pure
(newTransactionIntentWithPayer sponsorPk [senderSk, sponsorSk] [SystemProgram.transfer senderPk recipient 500_000_000] bh)
sig <- sendTransaction tx
confirmFinalized sig
recipientBal <- getBalance recipient
liftIO (recipientBal @?= 500_000_000)
senderBal <- getBalance senderPk
liftIO (senderBal @?= 500_000_000) -- sender paid NO fee: exactly the transfer left
sponsorBal <- getBalance sponsorPk
liftIO (assertBool "sponsor paid the fee" (sponsorBal < 2_000_000_000))
-- | A memo instruction's only account is its signer as a /read-only/ signer,
-- so a fee payer derived from the instructions alone would be read-only and
-- the node would reject the transaction at sanitize time. 'newTransaction'
-- must name its first key as the (writable) fee payer instead.
memoOnly :: Web3 ()
memoOnly = do
(payerPk, payerSk) <- fundedKeypair 1_000_000_000
sig <- newTransaction [payerSk] [Memo.buildMemo "solana-haskell-sdk" [payerPk]]
confirmFinalized sig
-- | With no signing key there is no fee payer; 'newTransaction' must say so
-- before touching the network rather than submit a signature-less
-- transaction for the node to reject.
noSigners :: Assertion
noSigners = do
pk <- fst <$> createSolanaKeyPair
recipient <- fst <$> createSolanaKeyPair
r <- try @SomeException (run (newTransaction [] [SystemProgram.transfer pk recipient 1]))
case r of
Left e -> assertBool ("expected a \"no signers\" error, got: " <> show e) ("no signers" `isInfixOf` show e)
Right sig -> assertFailure ("sent a transaction with no signers: " <> show sig)
-- | The first key given to 'newTransaction' pays the fee even if no
-- instruction references it (sponsored fees), and the remaining keys may be
-- passed in any order.
firstKeyPays :: Web3 ()
firstKeyPays = do
pairs <- fundedKeypairs [2_000_000_000, 1_000_000_000]
((sponsorPk, sponsorSk), (senderPk, senderSk)) <- case pairs of
[a, b] -> pure (a, b)
_ -> liftIO (throwIO (userError "expected two keypairs"))
recipient <- fst <$> liftIO createSolanaKeyPair
sig <- newTransaction [sponsorSk, senderSk] [SystemProgram.transfer senderPk recipient 500_000_000]
confirmFinalized sig
recipientBal <- getBalance recipient
liftIO (recipientBal @?= 500_000_000)
senderBal <- getBalance senderPk
liftIO (senderBal @?= 500_000_000) -- sender paid NO fee: exactly the transfer left
sponsorBal <- getBalance sponsorPk
liftIO (assertBool "sponsor paid the fee" (sponsorBal < 2_000_000_000))