Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
46 changes: 21 additions & 25 deletions docs/oracle-dns-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,27 +62,20 @@ Examples:

## Response schema

Successful queries return UTF-8 JSON. Attributes correspond to the `ResultEnvelope` produced by the oracle:
Successful queries return a NeoVM-serialized **Struct** (use `StdLib.Deserialize(result)` in contracts).

```jsonc
{
"Name": "1alhai._domainkey.icloud.com",
"Type": "TXT",
"Answers": [
{
"Name": "1alhai._domainkey.icloud.com",
"Type": "TXT",
"Ttl": 299,
"Data": "\"k=rsa; p=...IDAQAB\""
}
]
}
```
Struct schema:

- `Envelope` (Struct, 3 items): `[Name, Type, Answers]`
- `Answer` (Struct, 4 items): `[Name, Type, Ttl, Data]`

- `Answers` mirrors the DoH response but normalizes record types and names.
- CERT records are returned verbatim in `Answers[].Data` (type, key tag, algorithm, base64 payload). Contracts can parse the certificate themselves if needed.
Notes:

- `Answers` mirrors the DoH answer section but normalizes record types and names.
- CERT records are returned verbatim in `Answer[3]` (type, key tag, algorithm, base64 payload). Contracts can parse the certificate themselves if needed.
- If the DoH server responds with NXDOMAIN, the oracle returns `OracleResponseCode.NotFound`.
- Responses exceeding `OracleResponse.MaxResultSize` yield `OracleResponseCode.ResponseTooLarge`.
- Results exceeding `OracleResponse.MaxResultSize` yield `OracleResponseCode.ResponseTooLarge`.
- Oracle `filter` is not supported for DNS responses in struct mode; pass an empty filter string.

## Contract usage example

Expand All @@ -97,19 +90,22 @@ public static void OnOracleCallback(string url, byte[] userData, int code, byte[
{
if (code != (int)OracleResponseCode.Success) throw new Exception("Oracle query failed");

var envelope = (Neo.SmartContract.Framework.Services.Neo.Json.JsonObject)StdLib.JsonDeserialize(result);
var answers = (Neo.SmartContract.Framework.Services.Neo.Json.JsonArray)envelope["Answers"];
var txt = (Neo.SmartContract.Framework.Services.Neo.Json.JsonObject)answers[0];
Storage.Put(Storage.CurrentContext, "dkim", txt["Data"].AsString());
// Envelope = [Name, Type, Answers]
var envelope = (object[])StdLib.Deserialize(result);
var answers = (object[])envelope[2];

// Answer = [Name, Type, Ttl, Data]
var first = (object[])answers[0];
Storage.Put(Storage.CurrentContext, "dkim", (string)first[3]);
}
```

Tips:

1. Always set `TYPE` when you need anything other than an A record.
2. Budget enough `gasForResponse` to cover JSON payload size (TXT records are often kilobytes).
2. Budget enough `gasForResponse` to cover payload size (TXT records are often kilobytes).
3. Validate TTL or fingerprint data before trusting it.
4. Combine oracle DNS data with existing filters (e.g., `Helper.JsonPath`/`OracleService.Filter`) if you only need a slice of the result.
4. DNS oracle responses do not support the oracle `filter`; request the record type you need and parse `Answers` in-contract.

## Manual testing

Expand All @@ -121,4 +117,4 @@ curl -s \
'https://cloudflare-dns.com/dns-query?name=1alhai._domainkey.icloud.com&type=TXT'
```

Compare the JSON payload with the data returned by your contract callback to ensure parity.
Compare the DNS answer content with `Answer[3]` returned by your contract callback (after `StdLib.Deserialize`).
58 changes: 56 additions & 2 deletions plugins/OracleService/OracleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,19 @@ private async Task ProcessRequestAsync(DataCache snapshot, OracleRequest req)

(OracleResponseCode code, string data) = await ProcessUrlAsync(req.Url);

Log($"[{req.OriginalTxid}] Process oracle request end:<{req.Url}>, responseCode:{code}, response:{data}");
Uri.TryCreate(req.Url, UriKind.Absolute, out Uri requestUri);
bool dnsStackOutput = requestUri is not null && requestUri.Scheme.Equals("dns", StringComparison.OrdinalIgnoreCase);
Log($"[{req.OriginalTxid}] Process oracle request end:<{req.Url}>, responseCode:{code}, response:{FormatResponseForLog(code, data, dnsStackOutput)}");

byte[] dnsStackBytes = null;
if (code == OracleResponseCode.Success && dnsStackOutput)
{
if (!TryDecodeDnsStackItemPayload(data, out dnsStackBytes))
{
code = OracleResponseCode.Error;
Log($"[{req.OriginalTxid}] Invalid DNS stack item payload.", LogLevel.Warning);
}
}

var oracleNodes = NativeContract.RoleManagement.GetDesignatedByRole(snapshot, Role.Oracle, height);
foreach (var (requestId, request) in NativeContract.Oracle.GetRequestsByUrl(snapshot, req.Url))
Expand All @@ -307,7 +319,20 @@ private async Task ProcessRequestAsync(DataCache snapshot, OracleRequest req)
{
try
{
result = Filter(data, request.Filter);
if (dnsStackOutput)
{
if (!string.IsNullOrEmpty(request.Filter))
throw new InvalidOperationException("Filter is not supported for dns: requests.");
if (dnsStackBytes is null)
throw new InvalidOperationException("Missing DNS stack item payload.");
if (dnsStackBytes.Length > OracleResponse.MaxResultSize)
throw new InvalidOperationException("DNS stack item payload exceeds oracle maximum result size.");
result = dnsStackBytes;
}
else
{
result = Filter(data, request.Filter);
}
}
catch (Exception ex)
{
Expand Down Expand Up @@ -528,6 +553,35 @@ public static byte[] Filter(string input, string filterArgs)
return afterObjects.ToByteArray(false);
}

private static string FormatResponseForLog(OracleResponseCode code, string data, bool dnsStackOutput)
{
if (code != OracleResponseCode.Success)
return data;

if (dnsStackOutput)
return string.IsNullOrEmpty(data) ? "<stackitem:empty>" : $"<stackitem:base64:{data.Length} chars>";

if (string.IsNullOrEmpty(data))
return data;

const int maxLen = 2048;
return data.Length <= maxLen ? data : data[..maxLen] + "...";
}

internal static bool TryDecodeDnsStackItemPayload(string payload, out byte[] result)
{
try
{
result = Convert.FromBase64String(payload);
return true;
}
catch (Exception ex) when (ex is ArgumentNullException or FormatException)
{
result = null;
return false;
}
}

private bool CheckTxSign(DataCache snapshot, Transaction tx, ConcurrentDictionary<ECPoint, byte[]> OracleSigns)
{
uint height = NativeContract.Ledger.CurrentIndex(snapshot) + 1;
Expand Down
66 changes: 43 additions & 23 deletions plugins/OracleService/Protocols/OracleDnsProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// modifications are permitted.

using Neo.Network.P2P.Payloads;
using Neo.VM;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
Expand All @@ -22,11 +23,13 @@
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using VmArray = Neo.VM.Types.Array;
using VmByteString = Neo.VM.Types.ByteString;
using VmInteger = Neo.VM.Types.Integer;
using VmStruct = Neo.VM.Types.Struct;

namespace Neo.Plugins.OracleService.Protocols;

Expand All @@ -35,7 +38,6 @@ namespace Neo.Plugins.OracleService.Protocols;
/// </summary>
class OracleDnsProtocol : IOracleProtocol
{
private const ushort DnsClassIN = 1;
private const int DnsHeaderSize = 12;
private static readonly MediaTypeHeaderValue DnsMessageMediaType = new("application/dns-message");
private sealed class ResponseTooLargeException : Exception { }
Expand Down Expand Up @@ -71,13 +73,6 @@ private sealed class ResultAnswer
public string Data { get; set; }
}

private sealed class ResultEnvelope
{
public string Name { get; set; }
public string Type { get; set; }
public ResultAnswer[] Answers { get; set; }
}

private static readonly IReadOnlyDictionary<string, int> RecordTypeLookup = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["A"] = 1,
Expand All @@ -97,10 +92,6 @@ private sealed class ResultEnvelope
RecordTypeLookup.ToDictionary(p => p.Value, p => p.Key);

private readonly HttpClient client;
private readonly JsonSerializerOptions resultSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly object syncRoot = new();
private bool configured;
private Uri endpoint;
Expand Down Expand Up @@ -199,18 +190,40 @@ public void Dispose()
})
.ToArray();

ResultEnvelope envelope = new()
byte[] stackBytes = SerializeStackItemEnvelope(queryName, recordTypeLabel, answers);
if (stackBytes.Length > OracleResponse.MaxResultSize)
return (OracleResponseCode.ResponseTooLarge, null);

return (OracleResponseCode.Success, Convert.ToBase64String(stackBytes));
}

private static byte[] SerializeStackItemEnvelope(string name, string recordType, IEnumerable<ResultAnswer> answers)
{
var envelope = new VmStruct
{
Name = queryName,
Type = recordTypeLabel,
Answers = answers
new VmByteString(Encoding.UTF8.GetBytes(name ?? string.Empty)),
new VmByteString(Encoding.UTF8.GetBytes(recordType ?? string.Empty))
};

string payload = JsonSerializer.Serialize(envelope, resultSerializerOptions);
if (Encoding.UTF8.GetByteCount(payload) > OracleResponse.MaxResultSize)
return (OracleResponseCode.ResponseTooLarge, null);
var answerArray = new VmArray();
foreach (var answer in answers ?? Enumerable.Empty<ResultAnswer>())
{
var item = new VmStruct
{
new VmByteString(Encoding.UTF8.GetBytes(answer?.Name ?? string.Empty)),
new VmByteString(Encoding.UTF8.GetBytes(answer?.Type ?? string.Empty)),
new VmInteger((long)(answer?.Ttl ?? 0)),
new VmByteString(Encoding.UTF8.GetBytes(answer?.Data ?? string.Empty))
};
answerArray.Add(item);
}

return (OracleResponseCode.Success, payload);
envelope.Add(answerArray);

return Neo.SmartContract.BinarySerializer.Serialize(envelope, ExecutionEngineLimits.Default with
{
MaxItemSize = (uint)OracleResponse.MaxResultSize
});
}

/// <summary>
Expand All @@ -225,7 +238,14 @@ private async Task<DnsMessage> ResolveAsync(string name, ushort type, Uri resolv

await EnsureEndpointAllowed(resolverEndpoint, cancellation);

using HttpResponseMessage response = await client.PostAsync(resolverEndpoint, content, cancellation);
using HttpRequestMessage request = new(HttpMethod.Post, resolverEndpoint)
{
Content = content,
Version = HttpVersion.Version20,
VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher
};

using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"DoH endpoint returned {(int)response.StatusCode} ({response.StatusCode})");

Expand Down
Loading
Loading