Skip to content
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net6.0</TargetFramework>

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" />
<PackageReference Include="Moq" Version="4.12.0" />
<PackageReference Include="Stratis.SmartContracts.CLR" Version="2.0.2" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
<PackageReference Include="FluentAssertions" Version="5.10.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using FluentAssertions;
using Moq;
using Moq.Language.Flow;
using NBitcoin;
using NBitcoin.Networks;
using NonFungibleTokenContract.Tests;
using Stratis.SmartContracts;
using System;
Expand All @@ -15,24 +17,37 @@ public class NonFungibleTokenTests
private Mock<IContractLogger> contractLoggerMock;
private InMemoryState state;
private Mock<IInternalTransactionExecutor> transactionExecutorMock;
private ISerializer serializer;
private Address contractAddress;
private Network network;
private string name;
private string symbol;
private bool ownerOnlyMinting;

class ChameleonNetwork : Network
{
public ChameleonNetwork(byte base58Prefix)
{
this.Base58Prefixes = new byte[][] { new byte[] { base58Prefix } };
}
}

public NonFungibleTokenTests()
{
this.contractLoggerMock = new Mock<IContractLogger>();
this.smartContractStateMock = new Mock<ISmartContractState>();
this.transactionExecutorMock = new Mock<IInternalTransactionExecutor>();
this.serializer = new Stratis.SmartContracts.CLR.Serialization.Serializer(new Stratis.SmartContracts.CLR.Serialization.ContractPrimitiveSerializerV2(null));
this.state = new InMemoryState();
this.smartContractStateMock.Setup(s => s.PersistentState).Returns(this.state);
this.smartContractStateMock.Setup(s => s.ContractLogger).Returns(this.contractLoggerMock.Object);
this.smartContractStateMock.Setup(x => x.InternalTransactionExecutor).Returns(this.transactionExecutorMock.Object);
this.smartContractStateMock.Setup(x => x.Serializer).Returns(this.serializer);
this.contractAddress = "0x0000000000000000000000000000000000000001".HexToAddress();
this.name = "Tickets Token";
this.symbol = "TCKT";
this.ownerOnlyMinting = true;
this.network = new ChameleonNetwork(119);
}

public string GetTokenURI(UInt256 tokenId) => $"https://example.com/api/tokens/{tokenId}";
Expand Down Expand Up @@ -590,6 +605,39 @@ public void TransferFrom_ValidTokenTransfer_MessageSender_TransfersTokenFrom_To(
contractLoggerMock.Verify(l => l.Log(It.IsAny<ISmartContractState>(), new NonFungibleToken.TransferLog { From = ownerAddress, To = targetAddress, TokenId = 1 }));
}

private string AddressToString(Address address)
{
var address160 = Stratis.SmartContracts.CLR.AddressExtensions.ToUint160(address);
return Stratis.SmartContracts.CLR.AddressExtensions.ToBase58Address(address160, this.network);
}

[Fact]
public void DelegatedTransfer_ValidTokenTransfer_MessageSender_TransfersTokenFrom_To()
{
var key = new Key();
var ownerAddress = Convert.ToHexString(key.PubKey.Hash.ToBytes()).HexToAddress();
var targetAddress = "0x0000000000000000000000000000000000000007".HexToAddress();
var contractAddress = "0x0000000000000000000000000000000000000000".HexToAddress();
state.SetAddress("IdToOwner:1", ownerAddress);
state.SetUInt256($"Balance:{ownerAddress}", 1);

smartContractStateMock.Setup(m => m.Message.Sender).Returns(ownerAddress);

var nonFungibleToken = CreateNonFungibleToken();
var uid = Guid.NewGuid();

string url = $"?uid={Convert.ToHexString(uid.ToByteArray().Reverse().ToArray())}&contract={this.AddressToString(contractAddress)}&method=DelegatedTransfer&from={this.AddressToString(ownerAddress)}&to={this.AddressToString(targetAddress)}&tokenId=1";
byte[] signature = Convert.FromBase64String(key.SignMessage(url));

nonFungibleToken.DelegatedTransfer(url, signature);

Assert.Equal(targetAddress, state.GetAddress("IdToOwner:1"));
Assert.Equal(0, state.GetUInt256($"Balance:{ownerAddress}"));
Assert.Equal(1, state.GetUInt256($"Balance:{targetAddress}"));

contractLoggerMock.Verify(l => l.Log(It.IsAny<ISmartContractState>(), new NonFungibleToken.TransferLog { From = ownerAddress, To = targetAddress, TokenId = 1 }));
}

[Fact]
public void TransferFrom_NFTokenOwnerZero_ThrowsException()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
using Stratis.SmartContracts;
using Stratis.SCL.Crypto;

/// <summary>
/// A non fungible token contract.
/// </summary>
public class NonFungibleToken : SmartContract
{
/// <summary>
/// Function to check for replays of signed transfers.
/// </summary>
/// <param name="transferID">A unique number identifying the transfer.</param>
/// <returns>True if the transfer had already been performed, false otherwise.</returns>
public bool KnownTransfer(UInt128 transferID) => State.GetBool($"Transfer:{transferID}");

/// <summary>
/// Records the <paramref name="transferID"/> of a signed transfer.
/// </summary>
/// <param name="transferID">A unique number identifying the transfer.</param>
private void SetKnownTransfer(UInt128 transferID) => State.SetBool($"Transfer:{transferID}", true);

/// <summary>
/// Function to check which interfaces are supported by this contract.
Expand Down Expand Up @@ -215,6 +229,52 @@ public void SafeTransferFrom(Address from, Address to, UInt256 tokenId, byte[] d
SafeTransferFromInternal(from, to, tokenId, data);
}

/// <summary>
/// <para>Throws if <see cref="signature"/> can't be resolved from <see cref="url"/>.</para>
/// <para>Throws if the following <see cref="url"/> fields are invalid:
/// <list type="bullet">
/// <item>Throws if "method" is not "DelegatedTransfer".</item>
/// <item>Throws if "contract" is not this.Address.</item>
/// <item>Throws if "uid" has successfully been used before.</item>
/// <item>Throws if "from" is not the current owner or has a different address prexix from the "to" address or contract address.</item>
/// <item>Throws if "to" is the zero address or has a different address prexix from the "to" address or contract address.</item>
/// <item>Throws if "tokenId" is not a valid NFT or does not belong to the signee.</item>
/// </list></para>
/// </summary>
/// <remarks>The caller is responsible to confirm that <see cref="to"/> is capable of receiving NFTs or else
/// they maybe be permanently lost.</remarks>
/// <param name="url">The url containing the method arguments.</param>
/// <param name="signature">The signature of the <paramref name="url"/> string signed by the owner.</param>
public void DelegatedTransfer(string url, byte[] signature)
{
string[] args = SSAS.GetURLArguments(url, new string[] { "uid", "contract", "method", "from", "to", "tokenId" });

Assert(args != null && args.Length == 6 && args[2] == nameof(DelegatedTransfer), "Invalid url.");
Assert(Serializer.ToAddress(SSAS.ParseAddress(args[1], out byte prefix0)) == this.Address, "Invalid contract address.");

var uniqueNumber = UInt128.Parse($"0x{args[0]}");
Assert(!KnownTransfer(uniqueNumber), "The transfer has already been performed.");

var tokenId = UInt256.Parse(args[5]);
Assert(SSAS.TryGetSignerSHA256(Serializer.Serialize(url), signature, out Address signer), "Could not resolve signer.");
Assert(signer == GetIdToOwner(tokenId), "Invalid signature.");

// "ParseAddress" should work regardless of whether main or test address strings are passed.
var from = Serializer.ToAddress(SSAS.ParseAddress(args[3], out byte prefix1));
var to = Serializer.ToAddress(SSAS.ParseAddress(args[4], out byte prefix2));
Assert(prefix1 == prefix2, "'From' and 'To' address prefixes are different.");
Assert(prefix1 == prefix0, "Contract address versus 'From' and 'To' address prefixes are different.");

// Allow Message.Sender to perform the transfer.
SetIdToApproval(tokenId, Message.Sender);

SetKnownTransfer(uniqueNumber);

TransferFrom(from, to, tokenId);

LogDelegatedTransfer(from, to, tokenId, uniqueNumber, signature);
}

/// <summary>
/// Transfers the ownership of an NFT from one address to another address. This function can
/// be changed to payable.
Expand Down Expand Up @@ -423,6 +483,11 @@ private void LogTransfer(Address from, Address to, UInt256 tokenId)
Log(new TransferLog() { From = from, To = to, TokenId = tokenId });
}

private void LogDelegatedTransfer(Address from, Address to, UInt256 tokenId, UInt128 uniqueNumber, byte[] signature)
{
Log(new DelegatedTransferLog() { From = from, To = to, TokenId = tokenId, UniqueNumber = uniqueNumber, Signature = signature });
}

/// <summary>
/// This logs when the approved Address for an NFT is changed or reaffirmed. The zero
/// Address indicates there is no approved Address. When a Transfer logs, this also
Expand Down Expand Up @@ -612,6 +677,19 @@ private enum TokenInterface
ITicketContract = 100,
}

public struct DelegatedTransferLog
{
[Index]
public Address From;
[Index]
public Address To;
[Index]
public UInt256 TokenId;

public UInt128 UniqueNumber;
public byte[] Signature;
}

public struct TransferLog
{
[Index]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Stratis.SmartContracts" Version="2.0.0" />
<PackageReference Include="Stratis.SmartContracts.Standards" Version="1.0.0" />
<PackageReference Include="Stratis.SmartContracts.Standards" Version="2.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\..\StratisFullNode-1\src\Stratis.SCL\Stratis.SCL.csproj" />
</ItemGroup>

</Project>
10 changes: 8 additions & 2 deletions Testnet/NonFungibleToken-Ticket/NonFungibleTokenContract.sln
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29201.188
# Visual Studio Version 17
VisualStudioVersion = 17.4.33403.182
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NonFungibleTokenContract", "NonFungibleToken\NonFungibleTokenContract.csproj", "{D64B8959-5CC0-43D4-99B7-E07481222B5D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NonFungibleTokenContract.Tests", "NonFungibleToken.Tests\NonFungibleTokenContract.Tests.csproj", "{855863D4-4F60-47D0-AD2A-164749950614}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stratis.SCL", "..\..\..\StratisFullNode-1\src\Stratis.SCL\Stratis.SCL.csproj", "{D0399ECC-6A43-46F4-91AF-37B901661426}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -21,6 +23,10 @@ Global
{855863D4-4F60-47D0-AD2A-164749950614}.Debug|Any CPU.Build.0 = Debug|Any CPU
{855863D4-4F60-47D0-AD2A-164749950614}.Release|Any CPU.ActiveCfg = Release|Any CPU
{855863D4-4F60-47D0-AD2A-164749950614}.Release|Any CPU.Build.0 = Release|Any CPU
{D0399ECC-6A43-46F4-91AF-37B901661426}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D0399ECC-6A43-46F4-91AF-37B901661426}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D0399ECC-6A43-46F4-91AF-37B901661426}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D0399ECC-6A43-46F4-91AF-37B901661426}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
Loading