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: 2 additions & 0 deletions .ci-config/docker-compose.batchv11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ services:
- "127.0.0.1:5005:5005"
- "127.0.0.1:5006:5006"
- "127.0.0.1:6006:6006"
# credential-protected admin ws port, see [port_ws_admin_auth] in rippled.batchv11.cfg
- "127.0.0.1:6007:6007"
volumes:
- ./rippled.batchv11.cfg:/config/rippled.cfg:ro
- ./validators.txt:/config/validators.txt:ro
Expand Down
16 changes: 13 additions & 3 deletions .ci-config/docker-compose.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ services:
container_name: rippled-service
command: ["-a", "--start"]
ports:
- "5005:5005"
- "5006:5006"
- "6006:6006"
# Publish to loopback only, matching docker-compose.batchv11.yml. Every port below grants
# the admin role (rippled.cfg sets `admin = 0.0.0.0` on each stanza), and admin means
# `stop`, `connect`, `feature`, `validation_seed` — node control, not just reads. Only
# 6007 asks for credentials; the rest accept anyone who can reach them.
# Binding wider buys nothing: tests connect over localhost and the ledger-acceptor
# reaches the node through the compose network (http://xrpld:5006), not a published port.
# A host firewall is not a substitute — Docker's DNAT rules sit ahead of the chains ufw
# manages, so a published port stays reachable even with ufw enabled.
- "127.0.0.1:5005:5005"
- "127.0.0.1:5006:5006"
- "127.0.0.1:6006:6006"
# credential-protected admin ws port, see [port_ws_admin_auth] in rippled.cfg
- "127.0.0.1:6007:6007"
volumes:
- ./rippled.cfg:/config/rippled.cfg:ro
- ./validators.txt:/config/validators.txt:ro
Expand Down
10 changes: 10 additions & 0 deletions .ci-config/rippled.batchv11.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
port_rpc_admin_local
port_rpc_admin
port_ws_admin
port_ws_admin_auth

[port_rpc_admin_local]
port = 5005
Expand All @@ -21,6 +22,15 @@ ip = 0.0.0.0
protocol = ws
admin = 0.0.0.0

# Mirrors [port_ws_admin_auth] of rippled.cfg so TestIAdminCredentials behaves the same on both stands.
[port_ws_admin_auth]
port = 6007
ip = 0.0.0.0
protocol = ws
admin = 0.0.0.0
admin_user = xrpl_admin
admin_password = xrpl_admin_secret

[node_size]
small

Expand Down
15 changes: 15 additions & 0 deletions .ci-config/rippled.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
port_rpc_admin_local
port_rpc_admin
port_ws_admin
port_ws_admin_auth

[port_rpc_admin_local]
port = 5005
Expand All @@ -21,6 +22,20 @@ ip = 0.0.0.0
protocol = ws
admin = 0.0.0.0

# Credential-protected admin port used only by TestIAdminCredentials.
# rippled grants the admin role over ws/wss only when the client sends admin_user/admin_password
# inside the request JSON *and* its IP is in `admin` — so commands like ledger_accept are rejected
# here with `forbidden` / `Bad credentials.` unless XrplClient.ClientOptions.AdminUser/AdminPassword
# are set (requestRole returns Role::FORBID, it does not fall back to guest).
# Kept separate from port_ws_admin so the rest of the integration suite is unaffected.
[port_ws_admin_auth]
port = 6007
ip = 0.0.0.0
protocol = ws
admin = 0.0.0.0
admin_user = xrpl_admin
admin_password = xrpl_admin_secret

[node_size]
small

Expand Down
5 changes: 4 additions & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ reviews:
review_status: true

auto_review:
enabled: true
# Automatic review is off: reviews are requested on demand with
# "@coderabbitai review" in a PR comment. The settings below still apply
# whenever auto_review is switched back on.
enabled: false
auto_incremental_review: true
auto_pause_after_reviewed_commits: 0
drafts: false
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/dotnet.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,14 @@ jobs:
HOST: localhost
PORT: 6006

# x402 integration tests: hermetic E2E (against the standalone rippled above) plus the
# live t54 interop tests (TestILive*), which require the public XRPL testnet faucet and
# the t54 hosted facilitator.
# x402 integration tests: hermetic E2E against the standalone rippled above.
# The live t54 interop tests (TestCategory=Live) are excluded — they are the only tests
# in the suite that reach outside, needing the public XRPL testnet faucet and the hosted
# t54 facilitator, so leaving them in made a green build depend on two third-party
# services. Run them deliberately:
# dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --filter "TestCategory=Live"
- name: Test Integration (Xrpl.X402)
run: dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --verbosity normal --settings test.runsettings --filter "TestI"
run: dotnet test Tests/Xrpl.X402.Tests/Xrpl.X402.Tests.csproj --verbosity normal --settings test.runsettings --filter "TestI&TestCategory!=Live"
env:
HOST: localhost
PORT: 6006
Expand Down
157 changes: 99 additions & 58 deletions CHANGES.md

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,12 @@ Integration tests do **not** run on PRs into `dev` — `dev` uses a GitHub merge
### Release Process

1. Ensure all tests pass on `dev`
2. Update version in all `.csproj` files (`Xrpl`, `Xrpl.AddressCodec`, `Xrpl.BinaryCodec`, `Xrpl.Keypairs`)
3. Update `CHANGES.md`
4. Merge `dev` → `release`
5. NuGet publish triggers automatically on push to `release`
6. Create GitHub release with tag
2. Bump `<PackageVersion>` **only in the packages that actually changed** — not in all four. The base packages (`Xrpl.AddressCodec`, `Xrpl.BinaryCodec`, `Xrpl.Keypairs`) are consumed via `ProjectReference`, so a `Xrpl` package built at a newer version keeps depending on the already published base packages at their existing version. Leaving an untouched package behind is correct, not an oversight. Check with `git diff --stat origin/release...origin/dev -- Base/` before deciding
3. Choose the bump from the nature of the change: patch for a bugfix with no contract change, minor otherwise
4. Update `CHANGES.md`
5. Merge `dev` → `release`
6. NuGet publish triggers automatically on push to `release`
7. Create GitHub release with tag

### NuGet Packages Published

Expand Down
5 changes: 5 additions & 0 deletions DocFx/StandaloneNode-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ docker compose -f .ci-config/docker-compose.batchv11.yml down
| 5005 | JSON-RPC (admin) |
| 5006 | JSON-RPC (admin, used by the ledger-acceptor) |
| 6006 | WebSocket (admin) — integration tests connect here (`ws://localhost:6006`) |
| 6007 | WebSocket with `admin_user`/`admin_password` — used only by `TestIAdminCredentials` |

All ports are published on **loopback only** (`127.0.0.1`): the stand is reachable from the same machine and not from the network. That is deliberate — every stanza in the config sets `admin = 0.0.0.0`, so each port hands the admin role (`stop`, `connect`, `feature`, `validation_seed`) to anyone who can reach it, and only 6007 asks for credentials. If you genuinely need to reach the node from another host, widen the binding in the compose file as a conscious decision, not to make something work.

Port 6007 (`[port_ws_admin_auth]`) exists to prove that admin commands over WS open up only when `ClientOptions.AdminUser`/`AdminPassword` are set. rippled carries those credentials **inside the request JSON** — it never checks a Basic header on the ws handshake (its `user`/`password` port settings apply to plain HTTP JSON-RPC only). No other test uses this port.

## Why you get `temDISABLED`

Expand Down
5 changes: 5 additions & 0 deletions DocFx/StandaloneNode-Guide.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ docker compose -f .ci-config/docker-compose.batchv11.yml down
| 5005 | JSON-RPC (admin) |
| 5006 | JSON-RPC (admin, используется ledger-acceptor) |
| 6006 | WebSocket (admin) — интеграционные тесты подключаются сюда (`ws://localhost:6006`) |
| 6007 | WebSocket с `admin_user`/`admin_password` — только для `TestIAdminCredentials` |

Все порты публикуются **только на loopback** (`127.0.0.1`), поэтому стенд доступен с той же машины и недоступен из сети. Так и задумано: каждая станза в конфиге стоит с `admin = 0.0.0.0`, то есть даёт роль администратора (`stop`, `connect`, `feature`, `validation_seed`) любому, кто дотянулся до порта, а креды спрашивает только 6007. Если нужно ходить на ноду с другой машины — правьте привязку в compose-файле осознанно, а не «чтобы заработало».

Порт 6007 (`[port_ws_admin_auth]`) существует, чтобы проверить, что admin-команды по WS открываются только при заданных `ClientOptions.AdminUser`/`AdminPassword`. rippled передаёт эти креды **внутри JSON** запроса — Basic-заголовок на ws-рукопожатии сама нода не проверяет (её `user`/`password` относятся только к HTTP JSON-RPC). Остальные тесты порт не используют.

## Почему возникает `temDISABLED`

Expand Down
127 changes: 127 additions & 0 deletions Tests/Xrpl.Tests/Client/HandshakeCapturingServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

using Xrpl.Tests.MockRippled;

namespace Xrpl.Tests
{
/// <summary>
/// Minimal WebSocket server that captures the raw HTTP upgrade request of the first
/// client and then completes a valid handshake, so the client stays connected.
/// Used to assert which headers the SDK puts on the WebSocket handshake.
/// </summary>
internal sealed class HandshakeCapturingServer : IDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly TaskCompletionSource<string> _handshake =
new(TaskCreationOptions.RunContinuationsAsynchronously);

public HandshakeCapturingServer()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = AcceptAsync();
}

public int Port { get; }

public string Url => $"ws://127.0.0.1:{Port}/";

/// <summary>Raw text of the client's HTTP upgrade request, including all headers.</summary>
public Task<string> HandshakeRequest => _handshake.Task;

public async Task<string> WaitForHandshakeAsync(TimeSpan timeout)
{
Task completed = await Task.WhenAny(_handshake.Task, Task.Delay(timeout, _cts.Token))
.ConfigureAwait(false);

if (completed != _handshake.Task)
{
throw new TimeoutException($"No WebSocket handshake received within {timeout.TotalSeconds:F0}s.");
}

return await _handshake.Task.ConfigureAwait(false);
}

private async Task AcceptAsync()
{
try
{
using TcpClient client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
NetworkStream stream = client.GetStream();

string request = await ReadUntilHeadersEndAsync(stream).ConfigureAwait(false);
_handshake.TrySetResult(request);

string key = Helpers.GetHandshakeRequestKey(request);
byte[] response = Encoding.ASCII.GetBytes(Helpers.GetHandshakeResponse(Helpers.HashKey(key)));
await stream.WriteAsync(response, _cts.Token).ConfigureAwait(false);
await stream.FlushAsync(_cts.Token).ConfigureAwait(false);

// Hold the connection open until the test disposes the server.
await Task.Delay(Timeout.InfiniteTimeSpan, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_handshake.TrySetCanceled();
}
catch (Exception ex)
{
_handshake.TrySetException(ex);
}
}

private async Task<string> ReadUntilHeadersEndAsync(NetworkStream stream)
{
byte[] buffer = new byte[4096];
StringBuilder request = new StringBuilder();

while (true)
{
int read = await stream.ReadAsync(buffer, _cts.Token).ConfigureAwait(false);
if (read == 0)
{
break;
}

request.Append(Encoding.ASCII.GetString(buffer, 0, read));

if (request.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
{
break;
}
}

return request.ToString();
}

public void Dispose()
{
try
{
_cts.Cancel();
}
catch
{
// best effort
}

try
{
_listener.Stop();
}
catch
{
// best effort
}

_cts.Dispose();
}
}
}
Loading
Loading