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
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using StellarDotnetSdk.Converters;
using StellarDotnetSdk.Responses;
using StellarDotnetSdk.Responses.Predicates;

namespace StellarDotnetSdk.Tests.Responses;

Expand Down Expand Up @@ -37,17 +39,46 @@ public void TestSerializeDeserialize()

public static void AssertTestData(ClaimableBalanceResponse claimableBalance)
{
Assert.AreEqual("00000000c582697b67cbec7f9ce64f4dc67bfb2bfd26318bb9f964f4d70e3f41f650b1e6",
Assert.AreEqual("000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a",
claimableBalance.Id);
Assert.AreEqual("native", claimableBalance.Asset);
Assert.AreEqual("GB5N4275ETC6A77K4DTDL3EFAQMN66PC7UITDUZUBM7Y6LDJP7EYSGOB", claimableBalance.Sponsor);
Assert.AreEqual(66835, claimableBalance.LastModifiedLedger);
Assert.AreEqual("66835-00000000c582697b67cbec7f9ce64f4dc67bfb2bfd26318bb9f964f4d70e3f41f650b1e6",
Assert.AreEqual("govICE:GDERZDEWIYBPWFQLG7GV5BWC4BXSD5KCQ734D42P72IG5COAYIFB2DTB", claimableBalance.Asset);
Assert.AreEqual("16.6666667", claimableBalance.Amount);
Assert.AreEqual("GDERZDEWIYBPWFQLG7GV5BWC4BXSD5KCQ734D42P72IG5COAYIFB2DTB", claimableBalance.Sponsor);
Assert.AreEqual(65909, claimableBalance.LastModifiedLedger);
Assert.AreEqual(new DateTimeOffset(2025, 8, 18, 13, 02, 39, TimeSpan.Zero), claimableBalance.LastModifiedTime);
Assert.AreEqual("65909-000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a",
claimableBalance.PagingToken);

Assert.AreEqual(1, claimableBalance.Claimants.Length);
var claimant = claimableBalance.Claimants[0];
Assert.AreEqual("GD2I2F7SWUHBAD7XBIZTF7MBMWQYWJVEFMWTXK76NSYVOY52OJRYNTIY", claimant.Destination);
Assert.AreEqual(true, claimant.Predicate.Unconditional);
Assert.AreEqual(true, claimableBalance.Flags.ClawbackEnabled);

Assert.AreEqual(2, claimableBalance.Claimants.Length);
var claimant1 = claimableBalance.Claimants[0];
Assert.AreEqual("GARAAT5FYX52DGIETDXV5IEM7ZX3S645DCZ67ZLUNKBSNSLYL3UQKNQ6", claimant1.Destination);
Assert.IsInstanceOfType(claimant1.Predicate, typeof(PredicateOr));
var orPredicate = (PredicateOr)claimant1.Predicate;
Assert.IsInstanceOfType(orPredicate.Left, typeof(PredicateUnconditional));
Assert.IsInstanceOfType(orPredicate.Right, typeof(PredicateAnd));
var andPredicate = (PredicateAnd)orPredicate.Right;
Assert.IsInstanceOfType(andPredicate.Left, typeof(PredicateBeforeAbsoluteTime));
Assert.IsInstanceOfType(andPredicate.Right, typeof(PredicateUnconditional));

var absTimePredicate = (PredicateBeforeAbsoluteTime)andPredicate.Left;
Assert.AreEqual("2032-04-06T00:26:36Z", absTimePredicate.AbsBefore);
Assert.AreEqual(1964823996, absTimePredicate.AbsBeforeEpoch);

var claimant2 = claimableBalance.Claimants[1];
Assert.AreEqual("GCQ7BPXWUYUURVJMCCZDCQJOXPEW5HCDYEZD337GDGGOTBTW3N66PDHY", claimant2.Destination);
Assert.IsInstanceOfType(claimant2.Predicate, typeof(PredicateUnconditional));

Assert.IsNotNull(claimableBalance.Links);
Assert.AreEqual(
"https://horizon-testnet.stellar.org/claimable_balances/000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a",
claimableBalance.Links.Self.Href);
Assert.AreEqual(
"https://horizon-testnet.stellar.org/claimable_balances/000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a/operations{?cursor,limit,order}",
claimableBalance.Links.Operations.Href);
Assert.AreEqual(
"https://horizon-testnet.stellar.org/claimable_balances/000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a/transactions{?cursor,limit,order}",
claimableBalance.Links.Transactions.Href);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using StellarDotnetSdk.Converters;
using StellarDotnetSdk.Responses;
using StellarDotnetSdk.Responses.Operations;
using StellarDotnetSdk.Responses.Predicates;

namespace StellarDotnetSdk.Tests.Responses.Operations;

Expand Down Expand Up @@ -31,7 +33,9 @@ public void TestSerializationCreateClaimableBalanceAbsBeforeMaxIntOperation()
Assert.IsTrue(instance is CreateClaimableBalanceOperationResponse);
var operation = (CreateClaimableBalanceOperationResponse)instance;

Assert.IsNotNull(operation.Claimants[0].Predicate.AbsBefore);
Assert.IsInstanceOfType(operation.Claimants[0].Predicate, typeof(PredicateBeforeAbsoluteTime));
var absPredicate = (PredicateBeforeAbsoluteTime)operation.Claimants[0].Predicate;
Assert.IsNotNull(absPredicate.AbsBefore);
}

private static void AssertCreateClaimableBalanceData(OperationResponse instance)
Expand Down
130 changes: 117 additions & 13 deletions StellarDotnetSdk.Tests/Responses/PredicateDeserializerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using StellarDotnetSdk.Claimants;
using StellarDotnetSdk.Converters;
using StellarDotnetSdk.Responses;
using StellarDotnetSdk.Responses.Predicates;

namespace StellarDotnetSdk.Tests.Responses;

Expand All @@ -13,25 +14,128 @@ public class PredicateDeserializerTest
public void TestPredicateDeserialize()
{
const string json =
"{\"and\":[{\"or\":[{\"rel_before\":12},{\"abs_before\":\"2020-08-26T11:15:39Z\"}]},{\"not\":{\"unconditional\":true}}]}";
"{\"and\":[{\"or\":[{\"rel_before\":\"12\"},{\"abs_before\":\"2020-08-26T11:15:39Z\"}]},{\"not\":{\"unconditional\":true}}]}";
var predicate = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);
Assert.IsNotNull(predicate);

// Test polymorphic deserialization
Assert.IsInstanceOfType(predicate, typeof(PredicateAnd));
var andPredicate = (PredicateAnd)predicate;

Assert.IsInstanceOfType(andPredicate.Left, typeof(PredicateOr));
var orPredicate = (PredicateOr)andPredicate.Left;

Assert.IsInstanceOfType(andPredicate.Right, typeof(PredicateNot));
var notPredicate = (PredicateNot)andPredicate.Right;

Assert.IsInstanceOfType(orPredicate.Left, typeof(PredicateBeforeRelativeTime));
var relBefore = (PredicateBeforeRelativeTime)orPredicate.Left;
Assert.AreEqual(12, relBefore.RelBefore);

Assert.IsInstanceOfType(orPredicate.Right, typeof(PredicateBeforeAbsoluteTime));
var absBefore = (PredicateBeforeAbsoluteTime)orPredicate.Right;
Assert.AreEqual("2020-08-26T11:15:39Z", absBefore.AbsBefore);

Assert.IsInstanceOfType(notPredicate.Inner, typeof(PredicateUnconditional));

// Test conversion to ClaimPredicate
var claimPredicate = predicate.ToClaimPredicate();
Assert.IsInstanceOfType(claimPredicate, typeof(ClaimPredicateAnd));

var claimAnd = (ClaimPredicateAnd)claimPredicate;
Assert.IsInstanceOfType(claimAnd.LeftPredicate, typeof(ClaimPredicateOr));
Assert.IsInstanceOfType(claimAnd.RightPredicate, typeof(ClaimPredicateNot));
}

[TestMethod]
public void TestPredicateUnconditional()
{
const string json = "{\"unconditional\":true}";
var predicate = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);

Assert.IsNotNull(predicate);
Assert.IsInstanceOfType(predicate, typeof(PredicateUnconditional));

var claimPredicate = predicate.ToClaimPredicate();
Assert.IsInstanceOfType(claimPredicate, typeof(ClaimPredicateUnconditional));
}

[TestMethod]
public void TestPredicateBeforeAbsoluteTimeWithEpoch()
{
const string json = "{\"abs_before\":\"2020-08-26T11:15:39Z\",\"abs_before_epoch\":1598440539}";
var predicate = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);

Assert.IsNotNull(predicate);
Assert.IsInstanceOfType(predicate, typeof(PredicateBeforeAbsoluteTime));

var absPredicate = (PredicateBeforeAbsoluteTime)predicate;
Assert.AreEqual("2020-08-26T11:15:39Z", absPredicate.AbsBefore);
Assert.AreEqual(1598440539, absPredicate.AbsBeforeEpoch);
}

var andPredicate = (ClaimPredicateAnd)claimPredicate;
Assert.IsNotNull(andPredicate);
[TestMethod]
public void TestPredicateBeforeRelativeTime()
{
const string json = "{\"rel_before\":3600}";
var predicate = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);

Assert.IsNotNull(predicate);
Assert.IsInstanceOfType(predicate, typeof(PredicateBeforeRelativeTime));

var relPredicate = (PredicateBeforeRelativeTime)predicate;
Assert.AreEqual(3600, relPredicate.RelBefore);
}

[TestMethod]
public void TestPredicateSerialize()
{
var predicate = new PredicateAnd(
new PredicateOr(
new PredicateBeforeRelativeTime(12),
new PredicateBeforeAbsoluteTime("2020-08-26T11:15:39Z")
),
new PredicateNot(new PredicateUnconditional())
);

// Serialize with explicit base type to ensure converter is used
var json = JsonSerializer.Serialize<Predicate>(predicate, JsonOptions.DefaultOptions);
Assert.IsNotNull(json);

// Verify the JSON structure is correct
Assert.IsTrue(json.Contains("\"and\""), $"JSON should contain 'and' property. Actual: {json}");

// Deserialize back and verify
var deserialized = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);
Assert.IsNotNull(deserialized);
Assert.IsInstanceOfType(deserialized, typeof(PredicateAnd));
}

var orPredicate = (ClaimPredicateOr)andPredicate.LeftPredicate;
Assert.IsNotNull(orPredicate);
var notPredicate = (ClaimPredicateNot)andPredicate.RightPredicate;
Assert.IsNotNull(notPredicate);
[TestMethod]
public void TestPredicateBeforeRelativeTimeWithStringValue()
{
// Horizon API may return numeric fields as strings
const string json = "{\"rel_before\":\"3600\"}";
var predicate = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);

var relBefore = (ClaimPredicateBeforeRelativeTime)orPredicate.LeftPredicate;
Assert.IsNotNull(relBefore);
var absBefore = (ClaimPredicateBeforeAbsoluteTime)orPredicate.RightPredicate;
Assert.IsNotNull(absBefore);
Assert.IsNotNull(predicate);
Assert.IsInstanceOfType(predicate, typeof(PredicateBeforeRelativeTime));

var relPredicate = (PredicateBeforeRelativeTime)predicate;
Assert.AreEqual(3600, relPredicate.RelBefore);
}

[TestMethod]
public void TestPredicateBeforeAbsoluteTimeWithStringEpoch()
{
// Horizon API may return numeric fields as strings
const string json = "{\"abs_before\":\"2020-08-26T11:15:39Z\",\"abs_before_epoch\":\"1598440539\"}";
var predicate = JsonSerializer.Deserialize<Predicate>(json, JsonOptions.DefaultOptions);

Assert.IsNotNull(predicate);
Assert.IsInstanceOfType(predicate, typeof(PredicateBeforeAbsoluteTime));

var unconditional = (ClaimPredicateUnconditional)notPredicate.Predicate;
Assert.IsNotNull(unconditional);
var absPredicate = (PredicateBeforeAbsoluteTime)predicate;
Assert.AreEqual(1598440539, absPredicate.AbsBeforeEpoch);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"destination": "GAEJ2UF46PKAPJYED6SQ45CKEHSXV63UQEYHVUZSVJU6PK5Y4ZVA4ELU",
"predicate": {
"not": {
"relBefore": 604800
"rel_before": 604800
}
}
}
Expand Down
49 changes: 41 additions & 8 deletions StellarDotnetSdk.Tests/TestData/Responses/claimableBalance.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,54 @@
{
"_links": {
"self": {
"href": "https://horizon-protocol14.stellar.org/claimable_balances/00000000c582697b67cbec7f9ce64f4dc67bfb2bfd26318bb9f964f4d70e3f41f650b1e6"
"href": "https://horizon-testnet.stellar.org/claimable_balances/000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a"
},
"transactions": {
"href": "https://horizon-testnet.stellar.org/claimable_balances/000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a/transactions{?cursor,limit,order}",
"templated": true
},
"operations": {
"href": "https://horizon-testnet.stellar.org/claimable_balances/000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a/operations{?cursor,limit,order}",
"templated": true
}
},
"id": "00000000c582697b67cbec7f9ce64f4dc67bfb2bfd26318bb9f964f4d70e3f41f650b1e6",
"asset": "native",
"amount": "13.1200000",
"sponsor": "GB5N4275ETC6A77K4DTDL3EFAQMN66PC7UITDUZUBM7Y6LDJP7EYSGOB",
"last_modified_ledger": 66835,
"id": "000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a",
"asset": "govICE:GDERZDEWIYBPWFQLG7GV5BWC4BXSD5KCQ734D42P72IG5COAYIFB2DTB",
"amount": "16.6666667",
"sponsor": "GDERZDEWIYBPWFQLG7GV5BWC4BXSD5KCQ734D42P72IG5COAYIFB2DTB",
"last_modified_ledger": 65909,
"last_modified_time": "2025-08-18T13:02:39Z",
"claimants": [
{
"destination": "GD2I2F7SWUHBAD7XBIZTF7MBMWQYWJVEFMWTXK76NSYVOY52OJRYNTIY",
"destination": "GARAAT5FYX52DGIETDXV5IEM7ZX3S645DCZ67ZLUNKBSNSLYL3UQKNQ6",
"predicate": {
"or": [
{
"unconditional": true
},
{
"and": [
{
"abs_before": "2032-04-06T00:26:36Z",
"abs_before_epoch": "1964823996"
},
{
"unconditional": true
}
]
}
]
}
},
{
"destination": "GCQ7BPXWUYUURVJMCCZDCQJOXPEW5HCDYEZD337GDGGOTBTW3N66PDHY",
"predicate": {
"unconditional": true
}
}
],
"paging_token": "66835-00000000c582697b67cbec7f9ce64f4dc67bfb2bfd26318bb9f964f4d70e3f41f650b1e6"
"flags": {
"clawback_enabled": true
},
"paging_token": "65909-000000009832889118c5fcf2cfb3c082e079520c516300b5276b839cb934cf88ebc9244a"
}
24 changes: 24 additions & 0 deletions StellarDotnetSdk/Claimants/ClaimPredicate.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
using System;
using StellarDotnetSdk.Responses.Predicates;
using StellarDotnetSdk.Xdr;

namespace StellarDotnetSdk.Claimants;

/// <summary>
/// Represents a claim predicate for building claimable balance operations in Stellar transactions.
/// </summary>
/// <remarks>
/// <para>
/// This abstract class and its implementations are used when constructing
/// <see cref="StellarDotnetSdk.Operations.CreateClaimableBalanceOperation"/> to define
/// conditions under which a claimable balance can be claimed.
/// </para>
/// <para>
/// Predicates can be combined using logical operators:
/// <list type="bullet">
/// <item><see cref="And"/> - Both predicates must be true</item>
/// <item><see cref="Or"/> - At least one predicate must be true</item>
/// <item><see cref="Not"/> - The predicate must be false</item>
/// </list>
/// </para>
/// <para>
/// <strong>For deserializing Horizon API responses</strong>, use <see cref="Predicate"/> instead,
/// which can be converted to this type using <see cref="Predicate.ToClaimPredicate"/>.
/// </para>
/// </remarks>
/// <seealso cref="Predicate"/>
public abstract class ClaimPredicate
{
public abstract Xdr.ClaimPredicate ToXdr();
Expand Down
19 changes: 16 additions & 3 deletions StellarDotnetSdk/Claimants/Claimant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,35 @@

namespace StellarDotnetSdk.Claimants;

/// <summary>
/// Represents a claimant for building claimable balance operations in Stellar transactions.
/// </summary>
/// <remarks>
/// <para>
/// This class is used when constructing <see cref="StellarDotnetSdk.Operations.CreateClaimableBalanceOperation"/>
/// to specify who can claim the balance and under what conditions.
/// </para>
/// <para>
/// <strong>For deserializing Horizon API responses</strong>, use <see cref="Responses.Claimant"/> instead.
/// </para>
/// </remarks>
/// <seealso cref="Responses.Claimant"/>
public class Claimant
{
/// <summary>
/// Constructs a <c>Claimant</c> object from a public key and a predicate.
/// </summary>
/// <param name="recipientId">An Ed25519 public key.</param>
/// <param name="predicate">A <c>Predicate</c> object.</param>
/// <param name="predicate">A <see cref="ClaimPredicate"/> defining when this claimant can claim the balance.</param>
public Claimant(string recipientId, ClaimPredicate predicate) : this(KeyPair.FromAccountId(recipientId), predicate)
{
}

/// <summary>
/// Constructs a <c>Claimant</c> object from a key pair and a predicate.
/// </summary>
/// <param name="recipientId">An Ed25519 public key.</param>
/// <param name="predicate">A <c>Predicate</c> object.</param>
/// <param name="destination">The destination account that can claim the balance.</param>
/// <param name="predicate">A <see cref="ClaimPredicate"/> defining when this claimant can claim the balance.</param>
public Claimant(KeyPair destination, ClaimPredicate predicate)
{
Destination = destination;
Expand Down
Loading
Loading