@@ -498,3 +498,211 @@ def builder() -> TransactionBuilder:
498498
499499 # All four transactions, including the non-Void invocation, advanced the ledger.
500500 assert _rpc (server .port (), 'getLatestLedger' , {})['result' ]['sequence' ] == 4
501+
502+
503+ # ---------------------------------------------------------------------------
504+ # getTransactions / getLedgers (transaction history)
505+ #
506+ # Ground truth: the official OpenRPC spec (stellar-docs, methods/getTransactions.json and
507+ # methods/getLedgers.json) and the Go serialization structs from stellar/go-stellar-sdk
508+ # protocols/rpc, which is what real stellar-rpc emits. Notable serialization traps asserted
509+ # below:
510+ # - ledger sequences and the top-level close-time fields are JSON numbers,
511+ # - per-transaction `createdAt` in getTransactions is a JSON *number* (upstream quirk;
512+ # the singular getTransaction returns it as a string),
513+ # - per-ledger `ledgerCloseTime` in getLedgers is a *string* (Go int64 `,string`),
514+ # - XDR fields are `omitempty`: real base64 XDR or absent, never empty strings.
515+ # ---------------------------------------------------------------------------
516+
517+
518+ def _rpc_result (port : int , method : str , params : dict [str , Any ]) -> dict [str , Any ]:
519+ """Call an RPC method and return its result, failing the test on a JSON-RPC error."""
520+ response = _rpc (port , method , params )
521+ assert 'error' not in response , f'{ method } returned an error: { response ["error" ]} '
522+ return response ['result' ]
523+
524+
525+ def _is_hex64 (value : Any ) -> bool :
526+ return isinstance (value , str ) and len (value ) == 64 and all (c in '0123456789abcdef' for c in value )
527+
528+
529+ def _send_create_accounts (server : StellarRpcServer , count : int ) -> list [tuple [str , str ]]:
530+ """Submit ``count`` successful create-account transactions; return (hash, envelopeXdr) pairs.
531+
532+ Each successful transaction closes its own ledger, so after this call the latest ledger
533+ is ``count`` and transaction ``i`` (1-based) sits alone in ledger ``i``.
534+ """
535+ keypair = Keypair .random ()
536+ account = Account (keypair .public_key , sequence = 0 )
537+ sent : list [tuple [str , str ]] = []
538+ for _ in range (count ):
539+ envelope = (
540+ TransactionBuilder (account , Network .TESTNET_NETWORK_PASSPHRASE )
541+ .append_create_account_op (destination = keypair .public_key , starting_balance = '1000' )
542+ .set_timeout (30 )
543+ .build ()
544+ )
545+ envelope .sign (keypair )
546+ xdr_str = envelope .to_xdr ()
547+ send_res = _rpc (server .port (), 'sendTransaction' , {'transaction' : xdr_str })
548+ assert send_res ['result' ]['status' ] == 'PENDING'
549+ tx_hash = send_res ['result' ]['hash' ]
550+ assert _rpc (server .port (), 'getTransaction' , {'hash' : tx_hash })['result' ]['status' ] == 'SUCCESS'
551+ sent .append ((tx_hash , xdr_str ))
552+ return sent
553+
554+
555+ def test_get_transactions_spec_shape (server : StellarRpcServer ) -> None :
556+ """getTransactions returns the response shape of GetTransactionsResponse (Go SDK)."""
557+ before = int (time .time ())
558+ sent = _send_create_accounts (server , 3 )
559+ after = int (time .time ())
560+
561+ result = _rpc_result (server .port (), 'getTransactions' , {'startLedger' : 1 })
562+
563+ # All six top-level fields lack `omitempty` in the Go struct, so all must be present.
564+ required_keys = {
565+ 'transactions' ,
566+ 'latestLedger' ,
567+ 'latestLedgerCloseTimestamp' ,
568+ 'oldestLedger' ,
569+ 'oldestLedgerCloseTimestamp' ,
570+ 'cursor' ,
571+ }
572+ assert required_keys <= result .keys (), f'missing keys: { required_keys - result .keys ()} '
573+
574+ assert type (result ['latestLedger' ]) is int
575+ assert result ['latestLedger' ] == 3
576+ assert type (result ['latestLedgerCloseTimestamp' ]) is int
577+ assert type (result ['oldestLedger' ]) is int
578+ assert 0 <= result ['oldestLedger' ] <= 1
579+ assert type (result ['oldestLedgerCloseTimestamp' ]) is int
580+ assert isinstance (result ['cursor' ], str )
581+
582+ txs = result ['transactions' ]
583+ assert isinstance (txs , list )
584+ # All three transactions, in chain order (ascending ledger, then application order).
585+ assert [tx ['txHash' ] for tx in txs ] == [tx_hash for tx_hash , _ in sent ]
586+ for i , tx in enumerate (txs , start = 1 ):
587+ assert tx ['status' ] == 'SUCCESS'
588+ assert _is_hex64 (tx ['txHash' ])
589+ assert type (tx ['applicationOrder' ]) is int
590+ assert tx ['applicationOrder' ] == 1 # one transaction per ledger on this node
591+ assert tx ['feeBump' ] is False
592+ assert type (tx ['ledger' ]) is int
593+ assert tx ['ledger' ] == i
594+ # Upstream quirk: createdAt is a JSON number here (int64 without `,string` in Go),
595+ # unlike getTransaction (singular) where it is a string.
596+ assert type (tx ['createdAt' ]) is int
597+ assert before <= tx ['createdAt' ] <= after
598+ assert tx ['envelopeXdr' ] == sent [i - 1 ][1 ]
599+ # omitempty: XDR fields carry real base64 XDR or are absent — never empty strings.
600+ for optional in ('resultXdr' , 'resultMetaXdr' ):
601+ if optional in tx :
602+ assert isinstance (tx [optional ], str ) and tx [optional ] != ''
603+
604+ # xdrFormat: 'base64' is the default and must be accepted; unknown values are rejected.
605+ with_format = _rpc_result (server .port (), 'getTransactions' , {'startLedger' : 1 , 'xdrFormat' : 'base64' })
606+ assert [tx ['txHash' ] for tx in with_format ['transactions' ]] == [tx_hash for tx_hash , _ in sent ]
607+ assert _rpc (server .port (), 'getTransactions' , {'startLedger' : 1 , 'xdrFormat' : 'bogus' })['error' ]['code' ] == - 32602
608+
609+ # The limit for getTransactions ranges from 1 to 200.
610+ bad_limit = _rpc (server .port (), 'getTransactions' , {'startLedger' : 1 , 'pagination' : {'limit' : 201 }})
611+ assert bad_limit ['error' ]['code' ] == - 32602
612+
613+
614+ def test_get_transactions_pagination (server : StellarRpcServer ) -> None :
615+ """A limited page returns a cursor from which the next page resumes without overlap."""
616+ sent = _send_create_accounts (server , 3 )
617+
618+ page1 = _rpc_result (server .port (), 'getTransactions' , {'startLedger' : 1 , 'pagination' : {'limit' : 2 }})
619+ assert [tx ['txHash' ] for tx in page1 ['transactions' ]] == [sent [0 ][0 ], sent [1 ][0 ]]
620+ cursor = page1 ['cursor' ]
621+ assert isinstance (cursor , str ) and cursor != ''
622+
623+ # Resume from the cursor; startLedger must be omitted on cursor requests.
624+ page2 = _rpc_result (server .port (), 'getTransactions' , {'pagination' : {'cursor' : cursor , 'limit' : 2 }})
625+ assert [tx ['txHash' ] for tx in page2 ['transactions' ]] == [sent [2 ][0 ]]
626+
627+
628+ def test_get_transactions_invalid_params (server : StellarRpcServer ) -> None :
629+ port = server .port ()
630+ # startLedger beyond the latest ledger (0 on a fresh chain) is out of retention range.
631+ assert _rpc (port , 'getTransactions' , {'startLedger' : 999 })['error' ]['code' ] == - 32602
632+ # startLedger and cursor are mutually exclusive.
633+ both = _rpc (port , 'getTransactions' , {'startLedger' : 1 , 'pagination' : {'cursor' : '1' }})
634+ assert both ['error' ]['code' ] == - 32602
635+ # startLedger must be a number.
636+ assert _rpc (port , 'getTransactions' , {'startLedger' : 'one' })['error' ]['code' ] == - 32602
637+
638+
639+ def test_get_ledgers_spec_shape (server : StellarRpcServer ) -> None :
640+ """getLedgers returns the response shape of GetLedgersResponse (Go SDK)."""
641+ _send_create_accounts (server , 2 )
642+
643+ result = _rpc_result (server .port (), 'getLedgers' , {'startLedger' : 1 })
644+
645+ required_keys = {
646+ 'ledgers' ,
647+ 'latestLedger' ,
648+ 'latestLedgerCloseTime' ,
649+ 'oldestLedger' ,
650+ 'oldestLedgerCloseTime' ,
651+ 'cursor' ,
652+ }
653+ assert required_keys <= result .keys (), f'missing keys: { required_keys - result .keys ()} '
654+
655+ assert type (result ['latestLedger' ]) is int
656+ assert result ['latestLedger' ] == 2
657+ assert type (result ['latestLedgerCloseTime' ]) is int
658+ assert type (result ['oldestLedger' ]) is int
659+ assert 0 <= result ['oldestLedger' ] <= 1
660+ assert type (result ['oldestLedgerCloseTime' ]) is int
661+ assert isinstance (result ['cursor' ], str )
662+
663+ ledgers = result ['ledgers' ]
664+ assert isinstance (ledgers , list )
665+ assert [ledger ['sequence' ] for ledger in ledgers ] == [1 , 2 ]
666+ hashes = set ()
667+ for ledger in ledgers :
668+ assert type (ledger ['sequence' ]) is int
669+ assert _is_hex64 (ledger ['hash' ])
670+ hashes .add (ledger ['hash' ])
671+ # Per-ledger close time is a STRING containing a decimal number (Go int64 `,string`).
672+ assert isinstance (ledger ['ledgerCloseTime' ], str )
673+ assert ledger ['ledgerCloseTime' ].isdigit ()
674+ # headerXdr is a base64 LedgerHeaderHistoryEntry for this ledger.
675+ header = xdr .LedgerHeaderHistoryEntry .from_xdr (ledger ['headerXdr' ])
676+ assert header .header .ledger_seq .uint32 == ledger ['sequence' ]
677+ # metadataXdr is a base64 LedgerCloseMeta union for this ledger.
678+ meta = xdr .LedgerCloseMeta .from_xdr (ledger ['metadataXdr' ])
679+ meta_header = getattr (meta , f'v{ meta .v } ' ).ledger_header
680+ assert meta_header .header .ledger_seq .uint32 == ledger ['sequence' ]
681+ # Ledger hashes identify ledgers and must be unique.
682+ assert len (hashes ) == 2
683+
684+ # The limit for getLedgers ranges from 1 to 200.
685+ bad_limit = _rpc (server .port (), 'getLedgers' , {'startLedger' : 1 , 'pagination' : {'limit' : 201 }})
686+ assert bad_limit ['error' ]['code' ] == - 32602
687+
688+
689+ def test_get_ledgers_pagination (server : StellarRpcServer ) -> None :
690+ """A limited page returns a cursor from which the next page resumes without overlap."""
691+ _send_create_accounts (server , 2 )
692+
693+ page1 = _rpc_result (server .port (), 'getLedgers' , {'startLedger' : 1 , 'pagination' : {'limit' : 1 }})
694+ assert [ledger ['sequence' ] for ledger in page1 ['ledgers' ]] == [1 ]
695+ cursor = page1 ['cursor' ]
696+ assert isinstance (cursor , str ) and cursor != ''
697+
698+ page2 = _rpc_result (server .port (), 'getLedgers' , {'pagination' : {'cursor' : cursor , 'limit' : 1 }})
699+ assert [ledger ['sequence' ] for ledger in page2 ['ledgers' ]] == [2 ]
700+
701+
702+ def test_get_ledgers_invalid_params (server : StellarRpcServer ) -> None :
703+ port = server .port ()
704+ # startLedger beyond the latest ledger (0 on a fresh chain) is out of retention range.
705+ assert _rpc (port , 'getLedgers' , {'startLedger' : 999 })['error' ]['code' ] == - 32602
706+ # startLedger and cursor are mutually exclusive.
707+ both = _rpc (port , 'getLedgers' , {'startLedger' : 1 , 'pagination' : {'cursor' : '1' }})
708+ assert both ['error' ]['code' ] == - 32602
0 commit comments