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
4 changes: 2 additions & 2 deletions DotNut.Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ private static async Task P2PKDemo()

// Create a 1-of-2 multisig P2PK secret
Console.WriteLine("\n🏗️ Creating 1-of-2 multisig P2PK:");
var p2pkBuilder = new P2PkBuilder
var p2pkBuilder = new P2PKBuilder
{
Pubkeys = new[] { pubKey1, pubKey2 },
SignatureThreshold = 1, // 1-of-2 multisig
Expand All @@ -491,7 +491,7 @@ private static async Task P2PKDemo()

// Create a time-locked P2PK
Console.WriteLine("\n⏰ Creating time-locked P2PK:");
var timeLockedBuilder = new P2PkBuilder
var timeLockedBuilder = new P2PKBuilder
{
Pubkeys = new[] { pubKey1 },
SignatureThreshold = 1,
Expand Down
141 changes: 130 additions & 11 deletions DotNut.Tests/UnitTest1.cs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions DotNut/HTLCBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

namespace DotNut;

public class HTLCBuilder : P2PkBuilder
public class HTLCBuilder : P2PKBuilder
{
public string HashLock { get; set; }

/*
* ugly hack to reuse P2PkBuilder for HTLCs.
* P2PkBuilder expects a pubkey in `data` field, but we need to store a hashlock instead
* ugly hack to reuse P2PKBuilder for HTLCs.
* P2PKBuilder expects a pubkey in `data` field, but we need to store a hashlock instead
*
* we inject a dummy pubkey so the loader doesn’t break, then remove it after load/build.
*/
Expand All @@ -29,7 +29,7 @@ public static HTLCBuilder Load(HTLCProofSecret proofSecret)
Tags = proofSecret.Tags
};

var innerbuilder = P2PkBuilder.Load(tempProof);
var innerbuilder = P2PKBuilder.Load(tempProof);
innerbuilder.Pubkeys = innerbuilder.Pubkeys.Except([_dummy.Key]).ToArray();
return new HTLCBuilder()
{
Expand All @@ -50,7 +50,7 @@ public static HTLCBuilder Load(HTLCProofSecret proofSecret)
{
throw new ArgumentException("HashLock must be 32 bytes (64 chars hex)", nameof(HashLock));
}
var innerBuilder = new P2PkBuilder()
var innerBuilder = new P2PKBuilder()
{
Lock = Lock,
Pubkeys = Pubkeys.ToArray(),
Expand Down
20 changes: 16 additions & 4 deletions DotNut/HTLCProofSecret.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,28 @@ public class HTLCProofSecret : P2PKProofSecret
[JsonIgnore] public HTLCBuilder Builder => HTLCBuilder.Load(this);

public override ECPubKey[] GetAllowedPubkeys(out int requiredSignatures)
{
var builder = Builder;
requiredSignatures = builder.SignatureThreshold;
return builder.Pubkeys;
}

public override ECPubKey[] GetAllowedRefundPubkeys(out int? requiredSignatures)
{
var builder = Builder;
if (builder.Lock.HasValue && builder.Lock.Value.ToUnixTimeSeconds() < DateTimeOffset.Now.ToUnixTimeSeconds())
{
requiredSignatures = Math.Min(builder.RefundPubkeys?.Length ?? 0, 1);
return builder.RefundPubkeys ?? Array.Empty<ECPubKey>();
if (builder.RefundPubkeys == null)
{
requiredSignatures = 0; // proof is spendable without any signature
return [];
}
requiredSignatures = builder.RefundSignatureThreshold ?? 1;
return [..builder.RefundPubkeys??[]];
}

requiredSignatures = builder.SignatureThreshold;
return builder.Pubkeys;
requiredSignatures = null; // there's no refund condition :/
return [];
}


Expand Down
5 changes: 4 additions & 1 deletion DotNut/HTLCWitness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ namespace DotNut;

public class HTLCWitness: P2PKWitness
{
[JsonPropertyName("preimage")] public string Preimage { get; set; }
// this field is nullable now, because after locktime expiry only signatures are needed.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("preimage")]
public string? Preimage { get; set; }
}
3 changes: 0 additions & 3 deletions DotNut/JsonConverters/Nut10SecretJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ namespace DotNut.JsonConverters;

public class Nut10SecretJsonConverter : JsonConverter<Nut10Secret>
{



public override Nut10Secret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if(reader.TokenType == JsonTokenType.Null)
Expand Down
60 changes: 59 additions & 1 deletion DotNut/Nut10ProofSecret.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ namespace DotNut;

public class Nut10ProofSecret
{

[JsonPropertyName("nonce")]
public string Nonce { get; set; }

Expand All @@ -15,4 +14,63 @@ public class Nut10ProofSecret
[JsonPropertyName("tags")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[][]? Tags { get; set; }

public override bool Equals(object obj) => this.Equals(obj as Nut10ProofSecret);
public bool Equals(Nut10ProofSecret s)
{
if (s is null)
{
return false;
}

if (Object.ReferenceEquals(this, s))
{
return true;
}

if (this.GetType() != s.GetType())
{
return false;
}


return
this.Nonce == s.Nonce &&
this.Data == s.Data &&
((this.Tags == null && s.Tags == null) ||
(this.Tags != null && s.Tags != null && this.Tags.Length == s.Tags.Length &&
this.Tags.Zip(s.Tags).All(pair => pair.First.SequenceEqual(pair.Second))));
Comment on lines +40 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential NullReferenceException if inner tag arrays are null.

If Tags contains null elements (e.g., new string[][] { new[] {"a"}, null }), both SequenceEqual in Equals and the inner foreach in GetHashCode will throw. If null inner arrays are not valid by design, consider adding a guard or documenting this constraint.

🛡️ Optional defensive fix for Equals
-              this.Tags.Zip(s.Tags).All(pair => pair.First.SequenceEqual(pair.Second))));
+              this.Tags.Zip(s.Tags).All(pair => 
+                  (pair.First == null && pair.Second == null) ||
+                  (pair.First != null && pair.Second != null && pair.First.SequenceEqual(pair.Second)))));

Also applies to: 55-60

}

public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(this.Nonce);
hash.Add(this.Data);

if (this.Tags == null)
{
return hash.ToHashCode();
}
foreach (var tagArray in this.Tags)
{
foreach (var tag in tagArray)
{
hash.Add(tag);
}
}
return hash.ToHashCode();
}

public static bool operator ==(Nut10ProofSecret first, Nut10ProofSecret second)
{
if (first is null)
{
return second is null;
}
return first.Equals(second);
}

public static bool operator !=(Nut10ProofSecret first, Nut10ProofSecret second) => !(first == second);

}
37 changes: 32 additions & 5 deletions DotNut/P2PkBuilder.cs → DotNut/P2PKBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace DotNut;

public class P2PkBuilder
public class P2PKBuilder
{
public DateTimeOffset? Lock { get; set; }
public ECPubKey[]? RefundPubkeys { get; set; }
Expand All @@ -14,9 +14,11 @@ public class P2PkBuilder
//SIG_INPUTS, SIG_ALL
public string? SigFlag { get; set; }
public string? Nonce { get; set; }
public int? RefundSignatureThreshold { get; set; }

public P2PKProofSecret Build()
{
Validate();
var tags = new List<string[]>();
if (Pubkeys.Length > 1)
{
Expand All @@ -35,15 +37,20 @@ public P2PKProofSecret Build()
{
tags.Add(new[] { "refund" }.Concat(RefundPubkeys.Select(p => p.ToHex()))
.ToArray());
RefundSignatureThreshold ??= 1;

}
if (RefundSignatureThreshold is { } refundSignatureThreshold and > 1)
{
tags.Add(new[] {"n_sigs_refund", refundSignatureThreshold.ToString() });
}
}

if (SignatureThreshold > 1 && Pubkeys.Length >= SignatureThreshold)
{
tags.Add(new[] { "n_sigs", SignatureThreshold.ToString() });
}



return new P2PKProofSecret()
{
Data = Pubkeys.First().ToHex(),
Expand All @@ -52,9 +59,9 @@ public P2PKProofSecret Build()
};
}

public static P2PkBuilder Load(P2PKProofSecret proofSecret)
public static P2PKBuilder Load(P2PKProofSecret proofSecret)
{
var builder = new P2PkBuilder();
var builder = new P2PKBuilder();
var primaryPubkey = proofSecret.Data.ToPubKey();
var pubkeys = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "pubkeys");
if (pubkeys is not null && pubkeys.Length > 1)
Expand All @@ -77,6 +84,13 @@ public static P2PkBuilder Load(P2PKProofSecret proofSecret)
{
builder.RefundPubkeys = refund.Skip(1).Select(s => s.ToPubKey()).ToArray();
}

var nSigsRefund = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "n_sigs_refund")?
.Skip(1)?.FirstOrDefault();
if (!string.IsNullOrEmpty(nSigsRefund) && int.TryParse(nSigsRefund, out var nSigsRefundValue))
{
builder.RefundSignatureThreshold = nSigsRefundValue;
}

var sigFlag = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "sigflag")?.Skip(1)
?.FirstOrDefault();
Expand All @@ -97,6 +111,19 @@ public static P2PkBuilder Load(P2PKProofSecret proofSecret)
return builder;
}

private void Validate()
{
if (this.Pubkeys.Count() < SignatureThreshold)
{
throw new ArgumentException("Signature threshold bigger than provided pubkeys count!");
}
if(this.RefundSignatureThreshold is not null
&& (RefundPubkeys is null || RefundPubkeys.Length < RefundSignatureThreshold))
{
throw new ArgumentException("Signature threshold bigger than provided pubkeys count!");
}
}


/*
* =========================
Expand Down
Loading