Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ php -d memory_limit=-1 vendor/bin/phpunit --testsuite generated --no-coverage
composer analyse
```

- The unit suite (about 9,000 tests) runs in under 10 seconds and needs no network. Pass `php -d memory_limit=-1`, because the suite exceeds PHP's default limit of 128 MB and the Composer scripts `composer test` and `composer test:coverage` inherit that limit. Pass `--no-coverage` unless you collect coverage.
- The unit suite (about 9,000 tests) runs in under 30 seconds and needs no network; about 10 seconds of that are real waits in the Soroban polling tests. Pass `php -d memory_limit=-1`, because the suite exceeds PHP's default limit of 128 MB and the Composer scripts `composer test` and `composer test:coverage` inherit that limit. Pass `--no-coverage` unless you collect coverage.
- The `generated` suite holds the generated XDR tests; the `integration` suite runs against live testnet and futurenet, is not part of CI, and needs the pcntl extension for the streaming tests:

```sh
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- `AssembledTransaction` polls through `SorobanServer::getTransactionWithTimeout()`, so a `SorobanServer` subclass or test double that customizes the poll's status request overrides `getTransactionWithTimeout()`.

### Fixed
- TxRep parsing rejects a malformed integer or a value outside the field's XDR width with an `InvalidArgumentException` that names the field key and the text, replaces the text of a secret seed with `[redacted seed]`, and reads and writes uint64 values at or above 2^63 unsigned.
- Binary XDR decoding of the self-containing types (`XdrSCVal`, `XdrClaimPredicate`, `XdrSCSpecTypeDef`, `XdrSorobanAuthorizedInvocation` and others) stops at 500 nested levels with an `InvalidArgumentException`, and a failed decode leaves the buffer's depth counter unchanged.
- `ContractSpec::nativeToXdrSCVal` rejects a u32 or i32 integer outside its range and a `BytesN` string of another byte length with an `InvalidArgumentException` that names the value or length and the bound.
- `AssembledTransaction::send` and `signAndSend` poll every three seconds until `timeoutInSeconds`, retry status requests that fail in transport or with an RPC error, bound each request by the remaining time and the client's own limits, and name the last error when polling expires.

## [1.17.0] - 2026-10-24

Expand Down
62 changes: 41 additions & 21 deletions Soneso/StellarSDK/Soroban/Contract/AssembledTransaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@
*/
class AssembledTransaction
{
private const TRANSACTION_POLL_WAIT_SECONDS = 3.0;

/**
* @var TransactionBuilder|null The TransactionBuilder as constructed in `AssembledTransaction::build`
* Feel free set `simulate: false` in the method options to modify
Expand Down Expand Up @@ -419,8 +421,8 @@ public function simulate(?bool $restore = null) : void {
* - STATUS_SUCCESS: Transaction completed successfully
* - STATUS_FAILED: Transaction failed with error
* - STATUS_NOT_FOUND: Transaction timeout (throws exception before returning this)
* @throws Exception If signing fails, transaction is read-only without force, or timeout is exceeded
* @throws GuzzleException If the RPC request fails
* @throws Exception If signing fails, transaction is read-only without force, or polling expires before a terminal status
* @throws GuzzleException If the sendTransaction request fails
* @see sign() For signing without sending
* @see send() For sending an already-signed transaction
*/
Expand All @@ -438,8 +440,8 @@ public function signAndSend(?KeyPair $sourceAccountKeyPair = null, bool $force =
* until the transaction reaches a terminal state (SUCCESS, FAILED, or timeout).
*
* @return GetTransactionResponse The transaction result after completion
* @throws Exception If the transaction has not been signed, sending fails, or timeout is exceeded
* @throws GuzzleException If the RPC request fails
* @throws Exception If the transaction has not been signed, sending fails, or polling expires before a terminal status
* @throws GuzzleException If the sendTransaction request fails
*/
public function send() :GetTransactionResponse {
if ($this->signed === null) {
Expand Down Expand Up @@ -993,34 +995,52 @@ public function restoreFootprint(RestorePreamble $restorePreamble) : GetTransact
/**
* Polls the transaction status until it reaches a terminal state
*
* Queries getTransaction immediately, then retries with exponential backoff
* (1 second initial delay, factor 1.5) until the transaction is found or the
* configured timeout is exceeded.
* Waits up to three seconds before each request after the first, never past the deadline.
* Each request is bounded by the remaining time and the client's limits; the
* final request at expiry gets up to three seconds. Status request errors are
* retained for the timeout exception while polling continues.
*
* @internal This is an internal helper method used by send()
*
* @param string $transactionId The hash of the transaction to poll
* @return GetTransactionResponse The final transaction status
* @throws GuzzleException If the RPC request fails
* @throws Exception If the timeout is exceeded before the transaction completes
* @throws Exception If polling expires, including the last status request error when present
*/
private function pollStatus(string $transactionId) : GetTransactionResponse {
$deadline = microtime(true) + $this->options->methodOptions->timeoutInSeconds;
$waitTimeSeconds = 1.0;
$timeout = $this->options->methodOptions->timeoutInSeconds;
$started = hrtime(true);
$firstRequest = true;
$lastError = null;
while (true) {
$statusResponse = $this->server->getTransaction($transactionId);
if ($statusResponse->status !== GetTransactionResponse::STATUS_NOT_FOUND) {
return $statusResponse;
$remaining = $timeout - (hrtime(true) - $started) / 1e9;
$lastRequest = $remaining <= 0;
if (!$firstRequest && $remaining > 0) {
$lastRequest = $remaining <= self::TRANSACTION_POLL_WAIT_SECONDS;
usleep((int)(min(self::TRANSACTION_POLL_WAIT_SECONDS, $remaining) * 1000000));
}
$firstRequest = false;
$remaining = $timeout - (hrtime(true) - $started) / 1e9;
$lastRequest = $lastRequest || $remaining <= 0;
try {
$response = $this->server->getTransactionWithTimeout(
$transactionId, $lastRequest ? self::TRANSACTION_POLL_WAIT_SECONDS : $remaining,
);
if ($response->error !== null) {
$lastError = "RPC error code: {$response->error->code}, message: {$response->error->message}";
} elseif ($response->status === GetTransactionResponse::STATUS_SUCCESS
|| $response->status === GetTransactionResponse::STATUS_FAILED) {
return $response;
}
} catch (GuzzleException|\RuntimeException|\InvalidArgumentException $error) {
$lastError = $error->getMessage();
}
$remainingSeconds = $deadline - microtime(true);
if ($remainingSeconds <= 0) {
throw new Exception("Interrupted after waiting {$this->options->methodOptions->timeoutInSeconds}
seconds (options->timeoutInSeconds) for the transaction {$transactionId} to complete.");
if ($lastRequest) {
break;
}
// Never sleep past the deadline.
usleep((int)(min($waitTimeSeconds, $remainingSeconds) * 1000000));
$waitTimeSeconds *= 1.5;
}
throw new Exception("Interrupted after waiting {$timeout} seconds (options->methodOptions->timeoutInSeconds)"
. " for the transaction {$transactionId} to complete."
. ($lastError === null ? '' : " Last status request error: {$lastError}"));
}
/**
* Fetches the source account from the ledger
Expand Down
35 changes: 32 additions & 3 deletions Soneso/StellarSDK/Soroban/SorobanServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,36 @@ public function sendTransaction(Transaction $transaction) : SendTransactionRespo
* @throws GuzzleException if any request problem occurs.
*/
public function getTransaction(string $transactionId) : GetTransactionResponse {
return $this->requestTransaction($transactionId, []);
}

/**
* Fetches transaction status bounded by the supplied timeout and the client's own limits.
*
* @internal Used by AssembledTransaction polling.
* @param string $transactionId The transaction hash.
* @param float $timeoutSeconds The positive timeout in seconds.
* @return GetTransactionResponse The transaction status response.
* @throws GuzzleException If the status request fails.
*/
public function getTransactionWithTimeout(string $transactionId, float $timeoutSeconds): GetTransactionResponse {
$options = [];
foreach (['timeout', 'connect_timeout'] as $option) {
$clientTimeout = $this->httpClient->getConfig($option);
$options[$option] = $clientTimeout > 0 ? min($timeoutSeconds, $clientTimeout) : $timeoutSeconds;
}
return $this->requestTransaction($transactionId, $options);
}

/**
* @param string $transactionId The transaction hash.
* @param array<string,mixed> $options Guzzle per-request options.
* @return GetTransactionResponse The transaction status response.
* @throws GuzzleException If the status request fails.
*/
private function requestTransaction(string $transactionId, array $options): GetTransactionResponse {
$body = $this->prepareRequest(self::GET_TRANSACTION, ['hash' => $transactionId]);
$result = $this->request($body, self::GET_TRANSACTION);
$result = $this->request($body, self::GET_TRANSACTION, $options);
if (!$result instanceof GetTransactionResponse) {
throw new UnexpectedValueException('Expected GetTransactionResponse, got ' . get_class($result));
}
Expand Down Expand Up @@ -632,12 +660,13 @@ public function getContractData(
*
* @param string $body The JSON-RPC 2.0 request body as JSON string
* @param string $requestType The RPC method name (e.g., "simulateTransaction", "sendTransaction")
* @param array<string,mixed> $options Guzzle per-request options.
* @return SorobanRpcResponse The parsed response object
* @throws GuzzleException If the HTTP request fails
*/
private function request(string $body, string $requestType) : SorobanRpcResponse {
private function request(string $body, string $requestType, array $options = []) : SorobanRpcResponse {
$request = new Request("POST", $this->endpoint, $this->headers, $body);
$response = $this->httpClient->send($request);
$response = $this->httpClient->send($request, $options);
return $this->handleRpcResponse($response, $requestType);
}

Expand Down
129 changes: 127 additions & 2 deletions Soneso/StellarSDKTests/Unit/Soroban/AssembledTransactionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,12 @@ private function createMockedAssembledTransaction(
*
* Returns the mock handler so tests can assert the queue was fully consumed.
*/
private function injectMockedServer(AssembledTransaction $tx, array $responses): MockHandler
private function injectMockedServer(AssembledTransaction $tx, array $responses, array &$history = [], array $clientOptions = []): MockHandler
{
$mock = new MockHandler($responses);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$stack = HandlerStack::create($mock);
$stack->push(\GuzzleHttp\Middleware::history($history));
$client = new Client(['handler' => $stack] + $clientOptions);
$server = new SorobanServer($tx->options->clientOptions->rpcUrl, $client);

$serverProperty = (new \ReflectionClass($tx))->getProperty('server');
Expand Down Expand Up @@ -855,12 +857,135 @@ public function testSignAndSendPollsDuplicateSubmission(): void
$this->createGetTransactionSuccessResponse(),
]);

$started = hrtime(true);
$response = $tx->signAndSend();
$this->assertLessThan(1.0, (hrtime(true) - $started) / 1e9);

$this->assertSame(GetTransactionResponse::STATUS_SUCCESS, $response->status);
$this->assertSame(0, $mock->count());
}

#[\PHPUnit\Framework\Attributes\DataProvider('pollErrorKinds')]
public function testPollRecoversFromStatusError(bool $transport): void
{
$tx = $this->createWriteReadyAssembledTransaction();
$tx->options->methodOptions->timeoutInSeconds = 1;
$error = $transport
? new \GuzzleHttp\Exception\ConnectException('status connection lost', new \GuzzleHttp\Psr7\Request('POST', self::TEST_RPC_URL))
: new Response(200, [], '{"error":{"code":-32000,"message":"status unavailable"}}');
$mock = $this->injectMockedServer($tx, [
$this->createSendTransactionStatusResponse('PENDING'), $error,
$this->createGetTransactionSuccessResponse(),
]);
$this->assertSame(GetTransactionResponse::STATUS_SUCCESS, $tx->signAndSend()->status);
$this->assertSame(0, $mock->count());
}

public static function pollErrorKinds(): array
{
return ['transport' => [true], 'RPC' => [false]];
}

public function testPollDeadlineNamesLastErrorAndBoundsRequests(): void
{
$tx = $this->createWriteReadyAssembledTransaction();
$tx->options->methodOptions->timeoutInSeconds = 4;
$history = [];
$mock = $this->injectMockedServer($tx, [
$this->createSendTransactionStatusResponse('PENDING'),
new \GuzzleHttp\Exception\ConnectException('first error', new \GuzzleHttp\Psr7\Request('POST', self::TEST_RPC_URL)),
new Response(200, [], '{"error":{"code":-32001,"message":"last RPC error"}}'),
new Response(200, [], '{"result":{"status":"NOT_FOUND"}}'),
], $history);
$started = hrtime(true);
try {
$tx->signAndSend();
$this->fail('Expected the polling deadline to expire');
} catch (Exception $e) {
$elapsed = (hrtime(true) - $started) / 1e9;
$this->assertGreaterThanOrEqual(4.0, $elapsed);
$this->assertLessThan(6.0, $elapsed);
$this->assertStringContainsString('Interrupted after waiting 4 seconds', $e->getMessage());
$this->assertStringContainsString('options->methodOptions->timeoutInSeconds', $e->getMessage());
$this->assertStringContainsString(str_repeat('ab', 32), $e->getMessage());
$this->assertStringContainsString('Last status request error: RPC error code: -32001, message: last RPC error', $e->getMessage());
$this->assertStringNotContainsString('first error', $e->getMessage());
}
$this->assertSame(0, $mock->count());
$this->assertCount(4, $history);
$timeouts = [];
foreach (array_slice($history, 1) as $entry) {
$options = $entry['options'];
$this->assertArrayHasKey('timeout', $options);
$this->assertArrayHasKey('connect_timeout', $options);
$this->assertGreaterThan(0, $options['timeout']);
$this->assertSame($options['timeout'], $options['connect_timeout']);
$timeouts[] = $options['timeout'];
}
$this->assertLessThan(4.0, $timeouts[0]);
$this->assertGreaterThan(3.0, $timeouts[0]);
$this->assertLessThan($timeouts[0], $timeouts[1]);
$this->assertSame(3.0, $timeouts[2]);
}

#[\PHPUnit\Framework\Attributes\DataProvider('pollRequestExceptions')]
public function testPollDeadlineRetainsRequestError(Response|\Throwable $error, string $message): void
{
$tx = $this->createWriteReadyAssembledTransaction();
$tx->options->methodOptions->timeoutInSeconds = 0;
$history = [];
$this->injectMockedServer($tx, [
$this->createSendTransactionStatusResponse('PENDING'), $error,
], $history, ['http_errors' => false]);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Last status request error: ' . $message);
$tx->signAndSend();
}

public static function pollRequestExceptions(): array
{
return [
'GuzzleException' => [new class('status connection lost') extends Exception implements \GuzzleHttp\Exception\GuzzleException {}, 'status connection lost'],
'RuntimeException' => [new Response(503, [], 'status unavailable'), 'status unavailable'],
'InvalidArgumentException' => [new Response(200, [], '<html>unavailable</html>'), 'Error in json_decode: Syntax error'],
];
}

public function testPollExpiredDeadlineWithoutError(): void
{
$tx = $this->createWriteReadyAssembledTransaction();
$tx->options->methodOptions->timeoutInSeconds = 0;
$history = [];
$this->injectMockedServer($tx, [
$this->createSendTransactionStatusResponse('PENDING'),
new Response(200, [], '{"result":{"status":"NOT_FOUND"}}'),
], $history);
try {
$tx->signAndSend();
$this->fail('Expected the polling deadline to expire');
} catch (Exception $e) {
$this->assertStringContainsString('Interrupted after waiting 0 seconds', $e->getMessage());
$this->assertStringNotContainsString('Last status request error', $e->getMessage());
}
$options = $history[1]['options'];
$this->assertArrayHasKey('timeout', $options);
$this->assertArrayHasKey('connect_timeout', $options);
$this->assertSame(3.0, $options['timeout']);
$this->assertSame(3.0, $options['connect_timeout']);
}

public function testPollReturnsFailedStatus(): void
{
$tx = $this->createWriteReadyAssembledTransaction();
$tx->options->methodOptions->timeoutInSeconds = 0;
$mock = $this->injectMockedServer($tx, [
$this->createSendTransactionStatusResponse('PENDING'),
new Response(200, [], '{"result":{"status":"FAILED"}}'),
]);
$this->assertSame(GetTransactionResponse::STATUS_FAILED, $tx->signAndSend()->status);
$this->assertSame(0, $mock->count());
}

public function testSignAndSendThrowsOnTryAgainLater(): void
{
// TRY_AGAIN_LATER means the network did not accept the transaction, so
Expand Down
Loading
Loading