Table of Contents

Class NostrRfqTransport

Namespace
NArk.ArkadeIntents.Rfq
Assembly
NArk.ArkadeIntents.dll

The RFQ transport the protocol actually specifies: NIP-01 over a relay, addressed by pubkey.

public sealed class NostrRfqTransport : IRfqTransport, IDisposable
Inheritance
NostrRfqTransport
Implements
Inherited Members

Remarks

Both parties dial out and neither listens, which is what lets a solver run with no inbound port and no DNS name. It is also the only way to use the registry's rendezvous data, since a corridor card carries a discovery pubkey and relays rather than a URL.

Each negotiation uses a fresh identity key by default, which keeps separate swaps unlinkable to the relay operator and is also what keeps a busy client quotable: solvers meter quote creation per requester identity — the author key, on a relay — so one stable key spends a shared quota and starts drawing rate_limited refusals. A stable key costs one fewer ECDH per negotiation, which is the dominant per-message cost here, and is worth passing only where the traffic is low enough that the quota is not the binding constraint.

Constructors

NostrRfqTransport(IReadOnlyList<Uri>, string, Key?, TimeSpan?, ILogger<NostrRfqTransport>?)

Creates the transport against every relay the solver's card advertises.

public NostrRfqTransport(IReadOnlyList<Uri> relays, string solverPubkey, Key? identity = null, TimeSpan? timeout = null, ILogger<NostrRfqTransport>? logger = null)

Parameters

relays IReadOnlyList<Uri>

The rendezvous set, in the card's own order. Each is dialled and each carries the same request.

solverPubkey string

The solver's x-only key, hex — its address on the relay.

identity Key

Our own key. Omit for a fresh one per negotiation, which is the more private default.

timeout TimeSpan?

How long to wait for a reply before giving up. Defaults to 30s.

logger ILogger<NostrRfqTransport>

Optional logger; relay-level faults are reported here.

Remarks

Every relay is dialled at once and the first valid reply wins; the rest are torn down. That is not an optimisation but the point of a relay SET: a rendezvous is a place both parties happen to be, and neither side controls which of the card's relays the other is actually connected to at this moment. Dialling one and waiting is a coin flip dressed up as a protocol.

The same event goes to all of them — same id, same signature — so a solver connected to several sees duplicates of one request rather than several requests. The negotiation id makes that idempotent on its side.

Exceptions

ArgumentException

No relays, or a malformed solver key.

NostrRfqTransport(Uri, string, Key?, TimeSpan?, ILogger<NostrRfqTransport>?)

Creates the transport against a single relay.

public NostrRfqTransport(Uri relay, string solverPubkey, Key? identity = null, TimeSpan? timeout = null, ILogger<NostrRfqTransport>? logger = null)

Parameters

relay Uri

The relay to dial, e.g. wss://relay.example.

solverPubkey string

The solver's x-only key, hex — its address on the relay.

identity Key

Our own key. Omit for a fresh one per negotiation, which is the more private default.

timeout TimeSpan?

How long to wait for a reply before giving up. Defaults to 30s.

logger ILogger<NostrRfqTransport>

Optional logger; relay-level faults are reported here.

Remarks

A convenience over the relay-set constructor. A card carries a LIST, and dialling one entry of it makes a single operator's outage look exactly like a solver that declined to answer — so prefer passing everything the card advertises.

Fields

BroadcastKind

Open-RFQ broadcasts. Same range, same retention: none.

public const int BroadcastKind = 24860

Field Value

int

DirectedKind

Directed RFQ traffic. In NIP-01's ephemeral range, so relays forward it without storing it. Provisional, per the protocol spec.

public const int DirectedKind = 24859

Field Value

int

Remarks

The range matters to how this transport is written. Nothing is retained, so there is no backlog to catch up from — a subscription that is not already live when the reply is published misses it outright. Hence subscribing before publishing rather than after.

Methods

Dispose()

Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.

public void Dispose()

Remarks

Cancels any negotiation still waiting, so it ends as TransportClosedException rather than sitting out the full timeout and then reporting a solver that never failed.

ForCard(SolverCard, Key?, TimeSpan?, ILogger<NostrRfqTransport>?)

Build a transport from a solver's registry card, using every relay it advertises.

public static NostrRfqTransport ForCard(SolverCard card, Key? identity = null, TimeSpan? timeout = null, ILogger<NostrRfqTransport>? logger = null)

Parameters

card SolverCard

The card, as published or pinned locally.

identity Key

Our own key. Omit for a fresh one per negotiation.

timeout TimeSpan?

How long to wait for a reply. Defaults to 30s.

logger ILogger<NostrRfqTransport>

Optional logger.

Returns

NostrRfqTransport

A transport addressed to the card's discovery key over its whole relay set.

Remarks

The card is where a relay set comes from — a corridor card is REQUIRED to carry discovery_pubkey and transports, because its rendezvous is live data a maker will actually contact. Reading the list and then dialling one entry of it, which is what a caller writing this by hand tends to do, turns one operator's outage into what looks like a solver refusing to quote.

Non-wss:// entries are dropped rather than dialled. The registry's own schema admits only wss://, so a plaintext entry is either a malformed card or a downgrade someone wants us to accept — and this traffic is sealed to the solver's key but not to the relay's, so who carries it is still worth being strict about. Duplicates are collapsed: a card listing the same relay twice should cost one connection, not two.

Exceptions

ArgumentException

The card carries no discovery key, or no relay this client will dial.

GetStatusAsync<TStatusProfile>(string, CancellationToken)

Ask the solver where a negotiation stands.

public Task<RfqStatus<TStatusProfile>?> GetStatusAsync<TStatusProfile>(string rfqId, CancellationToken cancellationToken = default)

Parameters

rfqId string

The correlation id.

cancellationToken CancellationToken

Cancels the round trip.

Returns

Task<RfqStatus<TStatusProfile>>

The status, or null when the solver has no negotiation under that id.

Type Parameters

TStatusProfile

The corridor's status-profile shape.

Remarks

Best-effort by design — never the money path. A funded swap is observable on-chain whether or not the solver answers.

RequestEvmSendQuoteAsync(EvmSendRfqRequest, CancellationToken)

Asks for an Arkade-to-ERC20 quote using its current numeric-sats wire shape.

public Task<RfqQuote<EvmSendQuoteProfile>> RequestEvmSendQuoteAsync(EvmSendRfqRequest request, CancellationToken cancellationToken = default)

Parameters

request EvmSendRfqRequest

Strict exact-input EVM send request.

cancellationToken CancellationToken

Cancels the round trip.

Returns

Task<RfqQuote<EvmSendQuoteProfile>>

The solver's binding terms.

RequestQuoteAsync<TRequestProfile, TQuoteProfile>(RfqRequest<TRequestProfile>, CancellationToken)

Ask for a quote and return the solver's binding terms.

public Task<RfqQuote<TQuoteProfile>> RequestQuoteAsync<TRequestProfile, TQuoteProfile>(RfqRequest<TRequestProfile> request, CancellationToken cancellationToken = default)

Parameters

request RfqRequest<TRequestProfile>

The request payload; strict, so it must carry no extra fields.

cancellationToken CancellationToken

Cancels the round trip.

Returns

Task<RfqQuote<TQuoteProfile>>

The solver's quote for request.

Type Parameters

TRequestProfile

The corridor's request-profile shape.

TQuoteProfile

The corridor's quote-profile shape.

Exceptions

RfqRefusedException

The solver declined, with a reason from the closed set.

InvalidOperationException

The reply was neither a quote nor a refusal, or answered a different negotiation.