diff --git a/.editorconfig b/.editorconfig index 0d84513e..60513fe9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,6 @@ root = true [*] -end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 45d454ad..cf701d9d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -15,7 +15,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 830f2963..ad13f479 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,9 @@ jobs: - uses: actions/checkout@v2 - name: Setup Dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Pack Candid run: dotnet pack src/Candid/EdjCase.ICP.Candid.csproj --configuration Release /p:Version=${{ github.event.release.tag_name }} --output . --include-symbols --include-source @@ -33,6 +33,12 @@ jobs: - name: Push Agent run: dotnet nuget push EdjCase.ICP.Agent.${{ github.event.release.tag_name }}.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + - name: Pack Agent + run: dotnet pack src/PocketIC/EdjCase.ICP.PocketIC.csproj --configuration Release /p:Version=${{ github.event.release.tag_name }} --output . --include-symbols --include-source + + - name: Push PocketIC + run: dotnet nuget push EdjCase.ICP.PocketIC.${{ github.event.release.tag_name }}.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + - name: Pack WebSockets run: dotnet pack src/WebSockets/EdjCase.ICP.WebSockets.csproj --configuration Release /p:Version=${{ github.event.release.tag_name }} --output . --include-symbols --include-source diff --git a/.vscode/launch.json b/.vscode/launch.json index f7b3d561..7cc21291 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/src/Sample/bin/Debug/net6.0/Sample.dll", + "program": "${workspaceFolder}/src/Sample/bin/Debug/net8.0/Sample.dll", "args": [], "cwd": "${workspaceFolder}/src/Sample", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console diff --git a/ICP.sln b/ICP.sln index 6b100093..936c45e2 100644 --- a/ICP.sln +++ b/ICP.sln @@ -49,7 +49,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.WebSockets", "sample EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSockets.Tests", "test\WebSockets.Tests\WebSockets.Tests.csproj", "{3015AFBC-B866-459F-B25C-4BEA00C2A91E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BLS.Tests", "test\BLS.Tests\BLS.Tests.csproj", "{213F30BA-D147-4291-93A3-13A8A006126D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BLS.Tests", "test\BLS.Tests\BLS.Tests.csproj", "{213F30BA-D147-4291-93A3-13A8A006126D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EdjCase.ICP.PocketIC", "src\PocketIC\EdjCase.ICP.PocketIC.csproj", "{051EE789-4283-48DA-81EF-4B8ADFD406D0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.PocketIC", "samples\Sample.PocketIC\Sample.PocketIC.csproj", "{E674253B-7B51-40D0-9F3D-805007174D31}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PocketIC.Tests", "test\PocketIC.Tests\PocketIC.Tests.csproj", "{02D1DDA8-7A9A-4355-BE95-DE1720D56055}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -117,6 +123,18 @@ Global {213F30BA-D147-4291-93A3-13A8A006126D}.Debug|Any CPU.Build.0 = Debug|Any CPU {213F30BA-D147-4291-93A3-13A8A006126D}.Release|Any CPU.ActiveCfg = Release|Any CPU {213F30BA-D147-4291-93A3-13A8A006126D}.Release|Any CPU.Build.0 = Release|Any CPU + {051EE789-4283-48DA-81EF-4B8ADFD406D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {051EE789-4283-48DA-81EF-4B8ADFD406D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {051EE789-4283-48DA-81EF-4B8ADFD406D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {051EE789-4283-48DA-81EF-4B8ADFD406D0}.Release|Any CPU.Build.0 = Release|Any CPU + {E674253B-7B51-40D0-9F3D-805007174D31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E674253B-7B51-40D0-9F3D-805007174D31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E674253B-7B51-40D0-9F3D-805007174D31}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E674253B-7B51-40D0-9F3D-805007174D31}.Release|Any CPU.Build.0 = Release|Any CPU + {02D1DDA8-7A9A-4355-BE95-DE1720D56055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02D1DDA8-7A9A-4355-BE95-DE1720D56055}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02D1DDA8-7A9A-4355-BE95-DE1720D56055}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02D1DDA8-7A9A-4355-BE95-DE1720D56055}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -133,6 +151,8 @@ Global {B4F9E828-BC3D-4EFF-9E21-53DD82928AB0} = {7FADA9D9-5FDA-4CFB-8A53-A578A61FBBA9} {3015AFBC-B866-459F-B25C-4BEA00C2A91E} = {F71B8320-C279-4A79-A8D4-4039DB39D522} {213F30BA-D147-4291-93A3-13A8A006126D} = {F71B8320-C279-4A79-A8D4-4039DB39D522} + {E674253B-7B51-40D0-9F3D-805007174D31} = {7FADA9D9-5FDA-4CFB-8A53-A578A61FBBA9} + {02D1DDA8-7A9A-4355-BE95-DE1720D56055} = {F71B8320-C279-4A79-A8D4-4039DB39D522} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3103E11A-E792-49EE-98C5-B2F3709DB088} diff --git a/samples/Sample.BlazorWebAssembly/Client/Sample.BlazorWebAssembly.Client.csproj b/samples/Sample.BlazorWebAssembly/Client/Sample.BlazorWebAssembly.Client.csproj index 204f5d03..57bf6286 100644 --- a/samples/Sample.BlazorWebAssembly/Client/Sample.BlazorWebAssembly.Client.csproj +++ b/samples/Sample.BlazorWebAssembly/Client/Sample.BlazorWebAssembly.Client.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable service-worker-assets.js diff --git a/samples/Sample.BlazorWebAssembly/Server/Sample.BlazorWebAssembly.Server.csproj b/samples/Sample.BlazorWebAssembly/Server/Sample.BlazorWebAssembly.Server.csproj index ae86b94d..aaa718e9 100644 --- a/samples/Sample.BlazorWebAssembly/Server/Sample.BlazorWebAssembly.Server.csproj +++ b/samples/Sample.BlazorWebAssembly/Server/Sample.BlazorWebAssembly.Server.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable diff --git a/samples/Sample.CLI/Sample.CLI.csproj b/samples/Sample.CLI/Sample.CLI.csproj index 30340a46..e628f954 100644 --- a/samples/Sample.CLI/Sample.CLI.csproj +++ b/samples/Sample.CLI/Sample.CLI.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 disable enable ICP.Sample.CLI diff --git a/samples/Sample.PocketIC/CanisterWasmModules/counter.wasm b/samples/Sample.PocketIC/CanisterWasmModules/counter.wasm new file mode 100644 index 00000000..84bb7846 Binary files /dev/null and b/samples/Sample.PocketIC/CanisterWasmModules/counter.wasm differ diff --git a/samples/Sample.PocketIC/PocketIc.Tests.cs b/samples/Sample.PocketIC/PocketIc.Tests.cs new file mode 100644 index 00000000..4a1e10fa --- /dev/null +++ b/samples/Sample.PocketIC/PocketIc.Tests.cs @@ -0,0 +1,153 @@ +using System.Net; +using EdjCase.ICP.Agent.Agents; +using EdjCase.ICP.Agent.Responses; +using EdjCase.ICP.Candid.Models; +using EdjCase.ICP.PocketIC; +using EdjCase.ICP.PocketIC.Client; +using EdjCase.ICP.PocketIC.Models; +using Newtonsoft.Json; +using Org.BouncyCastle.Asn1.Cms; +using Xunit; + +namespace Sample.PocketIC +{ + public class PocketIcServerFixture : IDisposable + { + public PocketIcServer Server { get; private set; } + + public PocketIcServerFixture() + { + // Start the server for all tests + this.Server = PocketIcServer.Start().GetAwaiter().GetResult(); + } + + public void Dispose() + { + // Stop the server after all tests + if (this.Server != null) + { + this.Server.StopAsync().GetAwaiter().GetResult(); + this.Server.DisposeAsync().GetAwaiter().GetResult(); + } + } + } + + public class PocketIcTests : IClassFixture + { + private readonly PocketIcServerFixture fixture; + private string url => this.fixture.Server.GetUrl(); + + public PocketIcTests(PocketIcServerFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task UpdateCallAsync_CounterWasm__Basic__Valid() + { + byte[] wasmModule = File.ReadAllBytes("CanisterWasmModules/counter.wasm"); + CandidArg arg = CandidArg.FromCandid(); + + // Create new pocketic instance for test, then dispose it + await using (PocketIc pocketIc = await PocketIc.CreateAsync(this.url)) + { + Principal canisterId = await pocketIc.CreateAndInstallCanisterAsync(wasmModule, arg); + + UnboundedUInt value = await pocketIc.QueryCallAsync( + Principal.Anonymous(), + canisterId, + "get" + ); + Assert.Equal((UnboundedUInt)0, value); + + + await pocketIc.UpdateCallNoResponseAsync( + Principal.Anonymous(), + canisterId, + "inc" + ); + + value = await pocketIc.QueryCallAsync( + Principal.Anonymous(), + canisterId, + "get" + ); + Assert.Equal((UnboundedUInt)1, value); + + await pocketIc.UpdateCallNoResponseAsync( + Principal.Anonymous(), + canisterId, + "set", + (UnboundedUInt)10 + ); + + value = await pocketIc.QueryCallAsync( + Principal.Anonymous(), + canisterId, + "get" + ); + + Assert.Equal((UnboundedUInt)10, value); + } + } + + + [Fact] + public async Task HttpGateway_CounterWasm__Basic__Valid() + { + byte[] wasmModule = File.ReadAllBytes("CanisterWasmModules/counter.wasm"); + CandidArg arg = CandidArg.FromCandid(); + + + SubnetConfig nnsSubnet = SubnetConfig.New(); // NNS subnet required for HttpGateway + + await using (PocketIc pocketIc = await PocketIc.CreateAsync(this.url, nnsSubnet: nnsSubnet)) + { + Principal canisterId = await pocketIc.CreateAndInstallCanisterAsync(wasmModule, arg); + + await pocketIc.StartCanisterAsync(canisterId); + + // Let time progress so that update calls get processed + await using (await pocketIc.AutoProgressTimeAsync()) + { + await using (HttpGateway httpGateway = await pocketIc.RunHttpGatewayAsync()) + { + HttpAgent agent = httpGateway.BuildHttpAgent(); + QueryResponse getResponse = await agent.QueryAsync(canisterId, "get", CandidArg.Empty()); + CandidArg getResponseArg = getResponse.ThrowOrGetReply(); + UnboundedUInt getResponseValue = getResponseArg.ToObjects(); + Assert.Equal((UnboundedUInt)0, getResponseValue); + + + CancellationTokenSource cts = new(TimeSpan.FromSeconds(5)); + CandidArg incResponseArg = await agent.CallAndWaitAsync(canisterId, "inc", CandidArg.Empty(), cancellationToken: cts.Token); + Assert.Equal(CandidArg.Empty(), incResponseArg); + + // This alternative also doesnt work + // RequestId requestId = await agent.CallAsync(canisterId, "inc", CandidArg.Empty()); + // ICTimestamp currentTime = await pocketIc.GetTimeAsync(); + // await pocketIc.SetTimeAsync(currentTime + TimeSpan.FromSeconds(5)); + // await pocketIc.TickAsync(5); + // CandidArg incResponseArg = await agent.WaitForRequestAsync(canisterId, requestId); + // Assert.Equal(CandidArg.Empty(), incResponseArg); + + getResponse = await agent.QueryAsync(canisterId, "get", CandidArg.Empty()); + getResponseArg = getResponse.ThrowOrGetReply(); + getResponseValue = getResponseArg.ToObjects(); + Assert.Equal((UnboundedUInt)1, getResponseValue); + + CandidArg setRequestArg = CandidArg.FromObjects((UnboundedUInt)10); + CandidArg setResponseArg = await agent.CallAndWaitAsync(canisterId, "set", setRequestArg); + Assert.Equal(CandidArg.Empty(), setResponseArg); + + getResponse = await agent.QueryAsync(canisterId, "get", CandidArg.Empty()); + getResponseArg = getResponse.ThrowOrGetReply(); + getResponseValue = getResponseArg.ToObjects(); + Assert.Equal((UnboundedUInt)10, getResponseValue); + + } + } + } + } + } +} diff --git a/samples/Sample.PocketIC/Sample.PocketIC.csproj b/samples/Sample.PocketIC/Sample.PocketIC.csproj new file mode 100644 index 00000000..048314ff --- /dev/null +++ b/samples/Sample.PocketIC/Sample.PocketIC.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/samples/Sample.RestAPI/Sample.RestAPI.csproj b/samples/Sample.RestAPI/Sample.RestAPI.csproj index d33c396e..f729b0b6 100644 --- a/samples/Sample.RestAPI/Sample.RestAPI.csproj +++ b/samples/Sample.RestAPI/Sample.RestAPI.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable diff --git a/samples/Sample.WebSockets/Sample.WebSockets.csproj b/samples/Sample.WebSockets/Sample.WebSockets.csproj index 83c128d6..42fa78ab 100644 --- a/samples/Sample.WebSockets/Sample.WebSockets.csproj +++ b/samples/Sample.WebSockets/Sample.WebSockets.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 enable enable diff --git a/src/Agent/API.xml b/src/Agent/API.xml index dff315b8..94dbe5b5 100644 --- a/src/Agent/API.xml +++ b/src/Agent/API.xml @@ -195,7 +195,19 @@ The candid arg to send with the request Optional. Specifies the relevant canister id if calling the root canister Optional. Token to cancel request - The id of the request that can be used to look up its status with `GetRequestStatusAsync` + The raw candid arg response + + + + Waits for a request to be processed and returns the candid response to the call. This is a helper + method built on top of `GetRequestStatusAsync` to wait for the response so it doesn't need to be + implemented manually + + The agent to use for the call + Canister to read state for + The unique identifier for the request + Optional. Token to cancel request + The raw candid arg response diff --git a/src/Agent/Agents/IAgent.cs b/src/Agent/Agents/IAgent.cs index b276ba67..4a600761 100644 --- a/src/Agent/Agents/IAgent.cs +++ b/src/Agent/Agents/IAgent.cs @@ -90,7 +90,7 @@ public static class IAgentExtensions /// The candid arg to send with the request /// Optional. Specifies the relevant canister id if calling the root canister /// Optional. Token to cancel request - /// The id of the request that can be used to look up its status with `GetRequestStatusAsync` + /// The raw candid arg response public static async Task CallAndWaitAsync( this IAgent agent, Principal canisterId, @@ -100,12 +100,31 @@ public static async Task CallAndWaitAsync( CancellationToken? cancellationToken = null) { RequestId id = await agent.CallAsync(canisterId, method, arg, effectiveCanisterId); + return await agent.WaitForRequestAsync(canisterId, id, cancellationToken); + } + /// + /// Waits for a request to be processed and returns the candid response to the call. This is a helper + /// method built on top of `GetRequestStatusAsync` to wait for the response so it doesn't need to be + /// implemented manually + /// + /// The agent to use for the call + /// Canister to read state for + /// The unique identifier for the request + /// Optional. Token to cancel request + /// The raw candid arg response + public static async Task WaitForRequestAsync( + this IAgent agent, + Principal canisterId, + RequestId requestId, + CancellationToken? cancellationToken = null + ) + { while (true) { cancellationToken?.ThrowIfCancellationRequested(); - RequestStatus? requestStatus = await agent.GetRequestStatusAsync(canisterId, id); + RequestStatus? requestStatus = await agent.GetRequestStatusAsync(canisterId, requestId); cancellationToken?.ThrowIfCancellationRequested(); diff --git a/src/Agent/README.md b/src/Agent/README.md index 1e16ed4f..43d6d250 100644 --- a/src/Agent/README.md +++ b/src/Agent/README.md @@ -39,9 +39,7 @@ IAgent agent = new HttpAgent(); // Create Candid arg to send in request ulong proposalId = 1234; -CandidArg arg = CandidArg.FromCandid( - CandidTypedValue.FromObject(proposalId) // Conversion can be C# or custom types -); +CandidArg arg = CandidArg.FromObjects(proposalId); // Make request to IC string method = "get_proposal_info"; @@ -107,12 +105,15 @@ TransferResult transferResult = await client.Transfer(transferArgs); ``` # Identities + Supported identity types: + - Ed25519/EdDSA - Secp256k1/ECDSA - Delegated ## From PEM file + ```cs IIdentity identity; using (StreamReader pemFile = File.OpenText("C:\\identity.pem")) @@ -124,12 +125,16 @@ IAgent agent = new HttpAgent(identity); ``` ## From private key + ### Ed25519 + ```cs byte[] privateKey = ...; Ed25519Identity identity = IdentityUtil.FromEd25519PrivateKey(privateKey); ``` + ### Secp256k1 + ```cs byte[] privateKey = ...; Secp256k1Identity identity = IdentityUtil.FromSecp256k1PrivateKey(privateKey); @@ -138,26 +143,34 @@ Secp256k1Identity identity = IdentityUtil.FromSecp256k1PrivateKey(privateKey); ## Generate new keys ### Ed25519 + ```cs Ed25519Identity identity = IdentityUtil.GenerateEd25519Identity(); ``` + ### Secp256k1 + ```cs Secp256k1Identity identity = IdentityUtil.GenerateSecp256k1Identity(); ``` ## From public/private key pair + ### Ed25519 + ```cs Ed25519Identity identity = new Ed25519Identity(publicKey, privateKey); ``` + ### Secp256k1 + ```cs Secp256k1Identity identity = new Secp256k1Identity(publicKey, privateKey); ``` ## Delegation -This is most commonly used with things like Internet Identity where another identity is delegated to sign + +This is most commonly used with things like Internet Identity where another identity is delegated to sign requests by the inner identity. ```cs @@ -169,10 +182,12 @@ var delegatedIdentity = new DelegationIdentity(innerIdentity, chain); ``` # WebGL Builds + Due to how WebGL works by converting C# to JS/WASM using IL2CPP there are a few additional steps to avoid -incompatibilities. +incompatibilities. + - UnityHttpClient - The .NET `HttpClient` does not work in many cases, so `UnityHttpClient` is added via Unity C# script. - ```cs - var client = new UnityHttpClient(); - var agent = new HttpAgent(client); - ``` \ No newline at end of file + ```cs + var client = new UnityHttpClient(); + var agent = new HttpAgent(client); + ``` diff --git a/src/Candid/API.xml b/src/Candid/API.xml index 6d65f8ca..fd14ab8d 100644 --- a/src/Candid/API.xml +++ b/src/Candid/API.xml @@ -665,6 +665,134 @@ Candid arg value + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The value of the first paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The value of the first paramter + The value of the second paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The type of the third parameter + The value of the first paramter + The value of the second paramter + The value of the third paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The type of the third parameter + The type of the fourth parameter + The value of the first paramter + The value of the second paramter + The value of the third paramter + The value of the fourth paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The type of the third parameter + The type of the fourth parameter + The type of the fifth parameter + The value of the first paramter + The value of the second paramter + The value of the third paramter + The value of the fourth paramter + The value of the fifth paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The type of the third parameter + The type of the fourth parameter + The type of the fifth parameter + The type of the sixth parameter + The value of the first paramter + The value of the second paramter + The value of the third paramter + The value of the fourth paramter + The value of the fifth paramter + The value of the sixth paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The type of the third parameter + The type of the fourth parameter + The type of the fifth parameter + The type of the sixth parameter + The type of the seventh parameter + The value of the first paramter + The value of the second paramter + The value of the third paramter + The value of the fourth paramter + The value of the fifth paramter + The value of the sixth paramter + The value of the seventh paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + + + + Helper method to create a candid arg with typed values + + The type of the first parameter + The type of the second parameter + The type of the third parameter + The type of the fourth parameter + The type of the fifth parameter + The type of the sixth parameter + The type of the seventh parameter + The type of the eighth parameter + The value of the first paramter + The value of the second paramter + The value of the third paramter + The value of the fourth paramter + The value of the fifth paramter + The value of the sixth paramter + The value of the seventh paramter + The value of the eighth paramter + (Optional) Override to the default candid converter + A raw candid arg with the converted parameters + @@ -1438,9 +1566,21 @@ + + + + + + + + + + + + An interface to specify if a class can be hashed by the `IHashFunction` @@ -1765,6 +1905,9 @@ + + + Dummy struct to represent the `reserved` candid type's value @@ -1793,6 +1936,9 @@ + + + A model representing a segment of a path for a state hash tree @@ -1834,6 +1980,9 @@ String value to convert + + + A candid type that is NOT a reference type. This type is known before any resolution @@ -2705,6 +2854,11 @@ otherwise false + + + Specifies the service and method of the func if is not an opaque reference, otherwise will be null + + The candid service definition the function lives in The name of the function diff --git a/src/Candid/Models/CandidArg.cs b/src/Candid/Models/CandidArg.cs index 95e5a729..896e8204 100644 --- a/src/Candid/Models/CandidArg.cs +++ b/src/Candid/Models/CandidArg.cs @@ -275,7 +275,247 @@ public static CandidArg FromCandid(params CandidTypedValue[] values) /// Candid arg value public static CandidArg Empty() { - return new CandidArg(new List()); + return new CandidArg([]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The value of the first paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, CandidConverter? candidConverter = null) + where T1 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The value of the first paramter + /// The value of the second paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The type of the third parameter + /// The value of the first paramter + /// The value of the second paramter + /// The value of the third paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, T3 value3, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + where T3 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter), + CandidTypedValue.FromObject(value3, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The type of the third parameter + /// The type of the fourth parameter + /// The value of the first paramter + /// The value of the second paramter + /// The value of the third paramter + /// The value of the fourth paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, T3 value3, T4 value4, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter), + CandidTypedValue.FromObject(value3, candidConverter), + CandidTypedValue.FromObject(value4, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The type of the third parameter + /// The type of the fourth parameter + /// The type of the fifth parameter + /// The value of the first paramter + /// The value of the second paramter + /// The value of the third paramter + /// The value of the fourth paramter + /// The value of the fifth paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter), + CandidTypedValue.FromObject(value3, candidConverter), + CandidTypedValue.FromObject(value4, candidConverter), + CandidTypedValue.FromObject(value5, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The type of the third parameter + /// The type of the fourth parameter + /// The type of the fifth parameter + /// The type of the sixth parameter + /// The value of the first paramter + /// The value of the second paramter + /// The value of the third paramter + /// The value of the fourth paramter + /// The value of the fifth paramter + /// The value of the sixth paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where T6 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter), + CandidTypedValue.FromObject(value3, candidConverter), + CandidTypedValue.FromObject(value4, candidConverter), + CandidTypedValue.FromObject(value5, candidConverter), + CandidTypedValue.FromObject(value6, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The type of the third parameter + /// The type of the fourth parameter + /// The type of the fifth parameter + /// The type of the sixth parameter + /// The type of the seventh parameter + /// The value of the first paramter + /// The value of the second paramter + /// The value of the third paramter + /// The value of the fourth paramter + /// The value of the fifth paramter + /// The value of the sixth paramter + /// The value of the seventh paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where T6 : notnull + where T7 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter), + CandidTypedValue.FromObject(value3, candidConverter), + CandidTypedValue.FromObject(value4, candidConverter), + CandidTypedValue.FromObject(value5, candidConverter), + CandidTypedValue.FromObject(value6, candidConverter), + CandidTypedValue.FromObject(value7, candidConverter) + ]); + } + + /// + /// Helper method to create a candid arg with typed values + /// + /// The type of the first parameter + /// The type of the second parameter + /// The type of the third parameter + /// The type of the fourth parameter + /// The type of the fifth parameter + /// The type of the sixth parameter + /// The type of the seventh parameter + /// The type of the eighth parameter + /// The value of the first paramter + /// The value of the second paramter + /// The value of the third paramter + /// The value of the fourth paramter + /// The value of the fifth paramter + /// The value of the sixth paramter + /// The value of the seventh paramter + /// The value of the eighth paramter + /// (Optional) Override to the default candid converter + /// A raw candid arg with the converted parameters + public static CandidArg FromObjects(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7, T8 value8, CandidConverter? candidConverter = null) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where T6 : notnull + where T7 : notnull + where T8 : notnull + { + return new CandidArg( + [ + CandidTypedValue.FromObject(value1, candidConverter), + CandidTypedValue.FromObject(value2, candidConverter), + CandidTypedValue.FromObject(value3, candidConverter), + CandidTypedValue.FromObject(value4, candidConverter), + CandidTypedValue.FromObject(value5, candidConverter), + CandidTypedValue.FromObject(value6, candidConverter), + CandidTypedValue.FromObject(value7, candidConverter), + CandidTypedValue.FromObject(value8, candidConverter) + ]); } /// diff --git a/src/Candid/Models/HashTree.cs b/src/Candid/Models/HashTree.cs index c4c5662a..43989a5e 100644 --- a/src/Candid/Models/HashTree.cs +++ b/src/Candid/Models/HashTree.cs @@ -237,7 +237,7 @@ public byte[] BuildRootHash() /// public override string ToString() { - switch(this.Type) + switch (this.Type) { case HashTreeType.Empty: return "Empty"; @@ -250,7 +250,7 @@ public override string ToString() return $"Fork: {{ Left: {left}, Right: {right} }}"; case HashTreeType.Labeled: (EncodedValue label, HashTree tree) = this.AsLabeled(); - return $"Labeled: {label.AsUtf8()}/{ByteUtil.ToHexString(label.Value)} {tree}"; + return $"Labeled: {label} {tree}"; default: throw new NotImplementedException(); } @@ -291,10 +291,20 @@ public UnboundedUInt AsNat() return LEB128.DecodeUnsigned(this.Value); } + private static Encoding utf8Encoding = new UTF8Encoding(false, true); + /// public override string ToString() { - return this.AsUtf8(); + try + { + return utf8Encoding.GetString(this.Value); + } + catch (DecoderFallbackException) + { + // If the string is not valid utf8, then return the hex string + return ByteUtil.ToHexString(this.Value); + } } /// diff --git a/src/Candid/Models/ICTimestamp.cs b/src/Candid/Models/ICTimestamp.cs index 3a6ae558..653c7201 100644 --- a/src/Candid/Models/ICTimestamp.cs +++ b/src/Candid/Models/ICTimestamp.cs @@ -99,17 +99,41 @@ public override string ToString() } /// - public static bool operator >= (ICTimestamp a, ICTimestamp b) + public static bool operator >=(ICTimestamp a, ICTimestamp b) { return a.NanoSeconds >= b.NanoSeconds; } + /// + public static bool operator >(ICTimestamp a, ICTimestamp b) + { + return a.NanoSeconds > b.NanoSeconds; + } + /// public static bool operator <=(ICTimestamp a, ICTimestamp b) { return a.NanoSeconds <= b.NanoSeconds; } + /// + public static bool operator <(ICTimestamp a, ICTimestamp b) + { + return a.NanoSeconds < b.NanoSeconds; + } + + /// + public static ICTimestamp operator +(ICTimestamp timestamp, TimeSpan timeSpan) + { + return new ICTimestamp(timestamp.NanoSeconds + GetNanosecondsFromTimeSpan(timeSpan)); + } + + /// + public static ICTimestamp operator -(ICTimestamp timestamp, TimeSpan timeSpan) + { + return new ICTimestamp(timestamp.NanoSeconds - GetNanosecondsFromTimeSpan(timeSpan)); + } + private static UnboundedUInt EpochNowInNanoseconds() { ulong nanoseconds = (ulong)(((DateTime.UtcNow - epoch).TotalMilliseconds + REPLICA_PERMITTED_DRIFT_MILLISECONDS) * 1_000_000); diff --git a/src/Candid/Models/RequestId.cs b/src/Candid/Models/RequestId.cs index 1945b2e8..b50966f8 100644 --- a/src/Candid/Models/RequestId.cs +++ b/src/Candid/Models/RequestId.cs @@ -1,4 +1,5 @@ using EdjCase.ICP.Candid.Crypto; +using EdjCase.ICP.Candid.Utilities; using System.Collections.Generic; using System.Linq; @@ -49,5 +50,11 @@ public byte[] ComputeHash(IHashFunction hashFunction) { return hashFunction.ComputeHash(this.RawValue); } + + /// + public override string ToString() + { + return ByteUtil.ToHexString(this.RawValue); + } } } diff --git a/src/Candid/Models/StatePath.cs b/src/Candid/Models/StatePath.cs index da1bc425..c3374905 100644 --- a/src/Candid/Models/StatePath.cs +++ b/src/Candid/Models/StatePath.cs @@ -1,4 +1,5 @@ using EdjCase.ICP.Candid.Crypto; +using EdjCase.ICP.Candid.Utilities; using System; using System.Collections.Generic; using System.Linq; @@ -39,6 +40,12 @@ public byte[] ComputeHash(IHashFunction hashFunction) .ToHashable() .ComputeHash(hashFunction); } + + /// + public override string ToString() + { + return string.Join("/", this.Segments.Select(s => s.ToString())); + } } /// @@ -107,5 +114,22 @@ public static implicit operator StatePathSegment(string value) { return FromString(value); } + + + private static UTF8Encoding utf8Encoding = new(false, true); // Throw on invalid bytes + + /// + public override string ToString() + { + try + { + return utf8Encoding.GetString(this.Value); + } + catch (DecoderFallbackException) + { + // If not valid UTF-8, return hex string + return ByteUtil.ToHexString(this.Value); + } + } } } \ No newline at end of file diff --git a/src/Candid/Models/Values/CandidFunc.cs b/src/Candid/Models/Values/CandidFunc.cs index 1da2be02..02d98c5e 100644 --- a/src/Candid/Models/Values/CandidFunc.cs +++ b/src/Candid/Models/Values/CandidFunc.cs @@ -19,7 +19,9 @@ public class CandidFunc : CandidValue /// public bool IsOpaqueReference { get; } - + /// + /// Specifies the service and method of the func if is not an opaque reference, otherwise will be null + /// public (CandidService Service, string Method)? ServiceInfo { get; } /// The candid service definition the function lives in diff --git a/src/ClientGenerator/EdjCase.ICP.ClientGenerator.csproj b/src/ClientGenerator/EdjCase.ICP.ClientGenerator.csproj index 9283694a..593919f1 100644 --- a/src/ClientGenerator/EdjCase.ICP.ClientGenerator.csproj +++ b/src/ClientGenerator/EdjCase.ICP.ClientGenerator.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 enable latest EdjCase.ICP.ClientGenerator diff --git a/src/PocketIC/API.xml b/src/PocketIC/API.xml new file mode 100644 index 00000000..becf601d --- /dev/null +++ b/src/PocketIC/API.xml @@ -0,0 +1,1214 @@ + + + + EdjCase.ICP.PocketIC + + + + + Interface for communicating with a PocketIC server and managing IC instances + + + + + Gets the base URL of the PocketIC server + + + + + Uploads a binary blob to the PocketIC server + + The binary data to upload + The blob id for later retrieval + + + + Downloads a previously uploaded blob from the PocketIC server + + The id of the blob to download + The binary data of the blob + + + + Verifies a canister signature + + The message that was signed + The public key to verify against + The root public key + The signature to verify + True if signature is valid, false otherwise + + + + Gets all PocketIC instances + + List of all instances and their status + + + + Creates a new PocketIC instance with the specified subnet configuration + + Optional application subnet configurations. Will create a single application subnet if not specified + Optional Bitcoin subnet configuration. Will not create if not specified + Optional fiduciary subnet configuration. Will not create if not specified + Optional Internet Identity subnet configuration. Will not create if not specified + Optional Network Nervous System subnet configuration. Will not create if not specified + Optional Service Nervous System subnet configuration. Will not create if not specified + Optional system subnet configurations. Will not create if not specified + Optional verified application subnet configurations + Whether to enable non-mainnet features. Defaults to false + A tuple containing the new instance id and topology information + + + + Deletes a PocketIC instance + + The id of the instance to delete + + + + Makes a query call to a canister + + The id of the PocketIC instance + The principal making the call + The target canister id + The method name to call + The raw candid request argument + Optional effective principal for the call + The raw candid response from the canister + + + + Gets the topology information for a PocketIC instance + + The id of the PocketIC instance + List of subnet topologies + + + + Gets the current timestamp of a PocketIC instance + + The id of the PocketIC instance + The current timestamp + + + + Gets pending canister HTTP requests + + The id of the PocketIC instance + The pending canister HTTP request + + + + Gets the cycles balance of a canister + + The id of the PocketIC instance + The canister id + The cycles balance of the canister + + + + Gets the stable memory of a canister + + The id of the PocketIC instance + The canister id + The stable memory bytes of the canister + + + + Gets the subnet id for a canister + + The id of the PocketIC instance + The canister id + The subnet id of the canister + + + + Gets the public key for a subnet + + The id of the PocketIC instance + The subnet id + The public key principal of the subnet + + + + Submits an ingress message to a canister without waiting for execution + + The id of the PocketIC instance + The principal sending the message + The target canister id + The method name to call + The raw candid request argument + Optional effective principal for the call + The raw candid response + + + + Executes an ingress message on a canister and waits for the response + + The id of the PocketIC instance + The principal sending the message + The target canister id + The method name to call + The raw candid request argument + Optional effective principal for the call + The raw candid response + + + + Waits for an ingress message to complete execution + + The id of the PocketIC instance + The id of the ingress message + Optional effective principal for the call + + + + Sets the current time of the IC instance + + The IC instance + The new timestamp + + + + Configures automatic time progression for the IC instance + + The IC instance + Optional delay between time updates + + + + Stops automatic time progression for the IC instance + + The IC instance + + + + Adds cycles to a canister + + The id of the IC instance + The canister id + The amount of cycles to add + The new cycles balance of the canister + + + + Sets the stable memory of a canister + + The id of the IC instance + The canister id + The new stable memory bytes + + + + Makes the IC produce and progress by one block + + The id of the IC instance + + + + Mocks a response to a canister HTTP request + + The id of the IC instance + The id of the HTTP request + The subnet id of the canister + The response to send + Additional responses to send + + + + Starts an HTTP gateway for handling requests to the IC instance + + The id of the IC instance + Optional port number to listen on + Optional list of domains to accept requests from + Optional HTTPS configuration + The URL of the HTTP gateway + + + + Stops the HTTP gateway for an IC instance + + The id of the IC instance + + + + Information about a PocketIC instance + + + + + The unique identifier for this instance + + + + + The current status of this instance + + + + + The status of a PocketIC instance + + + + + The instance is available for use + + + + + The instance has been deleted + + + + + Configuration for HTTPS support + + + + + Path to the TLS certificate file + + + + + Path to the private key file + + + + + Represents the topology information for a subnet + + + + + The subnet's principal id + + + + + The type of subnet + + + + + The subnet's seed bytes + + + + + The node ids in this subnet + + + + + The canister id ranges for this subnet + + + + + Represents a range of canister ids + + + + + The start of the canister id range + + + + + The end of the canister id range + + + + + The type of subnet + + + + + Application subnet + + + + + Bitcoin subnet + + + + + Fiduciary subnet + + + + + Internet Identity subnet + + + + + Network Nervous System subnet + + + + + Social Network System subnet + + + + + System subnet + + + + + Specifies an effective principal for message routing + + + + + The type of effective principal + + + + + The principal id + + + + + Types of effective principals + + + + + Subnet + + + + + Canister + + + + + Configuration for a subnet + + + + + Whether to enable deterministic time slicing + + + + + Whether to enable high instruction limits for benchmarking + + + + + The subnet state configuration + + + + + Helper function to create a new/blank subnet configuration + + (Optional) If true, will enable DTS. Null value will use the IC default + (Optional) If true, will enable benchamrk instruction limits. Null value will use the IC default + A config for a new subnet + + + + Helper function to create a subnet configuration from an existing state path + + The filesystem path to the ic_state directory to load data from + The id of the subnet being restored + (Optional) If true, will enable DTS. Null value will use the IC default + (Optional) If true, will enable benchamrk instruction limits. Null value will use the IC default + + + + + Configuration for subnet state initialization + + + + + The type of state configuration + + + + + Path to existing state, if loading from path + + + + + Subnet id if loading existing state + + + + + Creates configuration for a new subnet with empty state + + + + + Creates configuration for loading existing state from a path + + + + + The type of subnet state configuration + + + + + Create a new subnet with empty state + + + + + Load existing state from a path + + + + + HTTP request from a canister + + + + + The subnet id where the request originated + + + + + Unique identifier for this request + + + + + The HTTP method for the request + + + + + The target URL for the request + + + + + The HTTP headers for the request + + + + + The body of the request + + + + + Optional maximum size for the response + + + + + HTTP header for canister requests/responses + + + + + The header name + + + + + The header value + + + + + HTTP methods supported for canister HTTP calls + + + + + HTTP GET method + + + + + HTTP POST method + + + + + HTTP HEAD method + + + + + Base class for HTTP responses to canister HTTP requests + + + + + Successful HTTP response to a canister HTTP request + + + + + The HTTP status code + + + + + The response headers + + + + + The response body + + + + + Error response to a canister HTTP request + + + + + The reject code indicating the type of error + + + + + A message describing the error + + + + + The default implementation of the interface. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Request model for creating a new canister with optional settings + + + + + Optional canister settings to configure the new canister with + + + + + Optional amount of cycles to add to the new canister + + + + + Optional specific canister ID to create the canister with + + + + + Configuration settings for a canister + + + + + Optional list of principal IDs that can control this canister + + + + + Optional compute allocation in percentage of subnet capacity + + + + + Optional memory allocation in bytes + + + + + Optional freezing threshold in seconds + + + + + Optional reserved cycles limit in cycles + + + + + Request model for starting a canister + + + + + The ID of the canister to start + + + + + Request model for stopping a canister + + + + + The ID of the canister to stop + + + + + Request model for installing code on a canister + + + + + The ID of the target canister + + + + + The initialization/upgrade arguments in raw bytes + + + + + The WASM module bytes to install + + + + + The installation mode (install, reinstall, or upgrade) + + + + + The mode for installing code on a canister + + + + + Install new code on an empty canister + + + + + Replace existing code and clear state + + + + + Upgrade existing code while preserving state + + + + + Request model for updating canister settings + + + + + The ID of the canister to update + + + + + The new settings to apply to the canister + + + + + Response model returned when creating a new canister + + + + + The principal ID of the newly created canister + + + + + The main interface for interacting with a PocketIC instance. + PocketIC is a local canister smart contract testing platform for the Internet Computer. + + + + + The REST HTTP client for making requests to the PocketIC server + + + + + The unique identifier for this PocketIC instance + + + + + Creates and installs a new canister with the provided WASM module and initialization arguments. + + The WASM module bytes to install + The initialization arguments in candid format + Optional canister settings + Optional amount of cycles to add to the canister + Optional specific canister ID to use + The Principal ID of the created canister + + + + Creates a new canister with optional settings + + Optional canister settings + Optional amount of cycles to add + Optional specific canister ID to use + The response containing the created canister's info + + + + Starts an idle canister + + The ID of the canister to start + + + + Stops a running canister + + The ID of the canister to stop + + + + Installs WASM code on a canister + + The target canister ID + The WASM module bytes to install + The installation arguments in candid format + The installation mode (install, upgrade, reinstall) + + + + Creates and starts an HTTP gateway for this PocketIC instance for handling HTTP requests. + The gateway will expose an API endpoint for making HTTP requests to the IC instance. + When disposed, the gateway will be stopped. + NOTE: The gateway requires an NNS subnet to be running in the IC instance. + + Optional port number to listen on. If not specified, will choose an available port + Optional list of domains the gateway should accept requests from. Defaults to localhost if not specified + Optional HTTPS configuration if TLS support is needed + A disposable HttpGateway object that represents the running gateway and provides access to the gateway URL + + + + Makes the IC produce and progress by one or more blocks + + Number of ticks to execute + + + + Gets the current time of the IC + + The current IC timestamp + + + + Sets the current time of the IC + + The timestamp to set + + + + Enables automatic time progression for the IC instance until disposed + + Optional delay between time updates + A disposable object that will stop auto progression when disposed + + + + Gets the public key for the given subnet + + The subnet id to look up + The subnet public key principal + + + + Gets the subnet Id for a given canister + + The canister Id to look up + The subnet principal where the canister is hosted + + + + Gets the topology information for all subnets in this IC instance + + Whether to use cached topology info. Defaults to true + List of subnet topology information + + + + Gets the cycles balance of a canister + + The canister to check + The cycles balance + + + + Adds cycles to a canister + + The target canister ID + The amount of cycles to add + The new cycles balance + + + + Sets the stable memory contents of a canister + + The target canister + The stable memory bytes to set + + + + Gets the stable memory contents of a canister + + The canister to read from + The stable memory bytes + + + + Executes a query call on a canister with no arguments + + The principal making the call + The target canister ID + The method name to call + Optional effective principal for the call + The query response decoded as type TResponse + + + + Executes a query call on a canister with a single argument + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + Optional effective principal for the call + The query response decoded as type TResponse + + + + Executes a query call on a canister with two arguments + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + The second candid argument for the call + Optional effective principal for the call + The query response decoded as type TResponse + + + + Executes a query call on a canister with three arguments + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + The second candid argument for the call + The third candid argument for the call + Optional effective principal for the call + The query response decoded as type TResponse + + + + Executes a query call on a canister with a raw CandidArg + + The principal making the call + The target canister ID + The method name to call + The raw candid argument for the call + Optional effective principal for the call + The query response decoded as type TResponse + + + + Executes an update call on a canister with no arguments + + The principal making the call + The target canister ID + The method name to call + Optional effective principal for the call + The update response decoded as type TResponse + + + + Executes an update call on a canister with a single argument + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + Optional effective principal for the call + The update response decoded as type TResponse + + + + Executes an update call on a canister with two arguments + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + The second candid argument for the call + Optional effective principal for the call + The update response decoded as type TResponse + + + + Executes an update call on a canister with three arguments + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + The second candid argument for the call + The third candid argument for the call + Optional effective principal for the call + The update response decoded as type TResponse + + + + Executes an update call on a canister with no arguments and no response + + The principal making the call + The target canister ID + The method name to call + Optional effective principal for the call + + + + + Executes an update call on a canister with a single argument and no response + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + Optional effective principal for the call + + + + + Executes an update call on a canister with a two arguments and no response + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + The second candid argument for the call + Optional effective principal for the call + + + + + Executes an update call on a canister with a three arguments and no response + + The principal making the call + The target canister ID + The method name to call + The first candid argument for the call + The second candid argument for the call + The third candid argument for the call + Optional effective principal for the call + + + + + Executes an update call on a canister with a raw CandidArg and raw CandidArg response + + The principal making the call + The target canister ID + The method name to call + The raw candid argument for the call + Optional effective principal for the call + A raw candid argument from the response + + + + Disposes of the PocketIC instance by deleting the instance + + + + + + Creates a new PocketIC instance using an IPocketIcHttpClient instance + + The HTTP client to use + Optional application subnet configurations. Will create a single application subnet if not specified + Optional Bitcoin subnet configuration. Will not create if not specified + Optional fiduciary subnet configuration. Will not create if not specified + Optional Internet Identity subnet configuration. Will not create if not specified + Optional Network Nervous System subnet configuration. Will not create if not specified + Optional Service Nervous System subnet configuration. Will not create if not specified + Optional system subnet configurations. Will not create if not specified + Optional verified application subnet configurations + Whether to enable non-mainnet features. Defaults to false + Optional candid converter to use, otherwise will use the default + A new PocketIC instance + + + + Creates a new PocketIC instance + + The PocketIC server url + Optional application subnet configurations. Will create a single application subnet if not specified + Optional Bitcoin subnet configuration. Will not create if not specified + Optional fiduciary subnet configuration. Will not create if not specified + Optional Internet Identity subnet configuration. Will not create if not specified + Optional Network Nervous System subnet configuration. Will not create if not specified + Optional Service Nervous System subnet configuration. Will not create if not specified + Optional system subnet configurations. Will not create if not specified + Optional verified application subnet configurations + Whether to enable non-mainnet features. Defaults to false + Optional candid converter to use, otherwise will use the default + Optional request timeout for http requests. Defaults to 30 seconds + A new PocketIC instance + + + + Represents an HTTP gateway for accessing the Internet Computer. Disposing of this object will stop the gateway + + + + + The URL of the HTTP gateway + + + + + Creates a new HTTP agent configured to use this gateway + + Optional identity to use for the agent, otherwise will use anonymous identity + A configured HTTP agent + + + + Disposes of the HTTP gateway + + + + + A class to help start the pocket-ic server process + + + + + Gets the URL of the server + + + + + Stops the server process + + + + + Disposes of the server process + + + + + Starts the pocket-ic server process + + Outputs the runtime logs using Debug.WriteLine(...) + Outputs the error logs using Debug.WriteLine(...) + The instance of the PocketIcServer with the running process + + + diff --git a/src/PocketIC/EdjCase.ICP.PocketIC.csproj b/src/PocketIC/EdjCase.ICP.PocketIC.csproj new file mode 100644 index 00000000..573add03 --- /dev/null +++ b/src/PocketIC/EdjCase.ICP.PocketIC.csproj @@ -0,0 +1,43 @@ + + + + net8.0 + enable + enable + latest + EdjCase.ICP.PocketIC + https://github.com/EdjCase/ICP.NET + git + .net;blazor;ICP;IC;PocketIC + README.md + https://github.com/EdjCase/ICP.NET + EdjCase.ICP.PocketIC + Edjcase + Gekctek + EdjCase.ICP.PocketIC + True + API.xml + + + + + True + \ + + + + + + + true + runtimes + PreserveNewest + + + + + + + + + diff --git a/src/PocketIC/IPocketIcHttpClient.cs b/src/PocketIC/IPocketIcHttpClient.cs new file mode 100644 index 00000000..4427272a --- /dev/null +++ b/src/PocketIC/IPocketIcHttpClient.cs @@ -0,0 +1,662 @@ +using EdjCase.ICP.Candid.Models; + +namespace EdjCase.ICP.PocketIC.Client; + +/// +/// Interface for communicating with a PocketIC server and managing IC instances +/// +public interface IPocketIcHttpClient +{ + /// + /// Gets the base URL of the PocketIC server + /// + Uri GetServerUrl(); + + /// + /// Uploads a binary blob to the PocketIC server + /// + /// The binary data to upload + /// The blob id for later retrieval + Task UploadBlobAsync(byte[] blob); + + /// + /// Downloads a previously uploaded blob from the PocketIC server + /// + /// The id of the blob to download + /// The binary data of the blob + Task DownloadBlobAsync(string blobId); + + /// + /// Verifies a canister signature + /// + /// The message that was signed + /// The public key to verify against + /// The root public key + /// The signature to verify + /// True if signature is valid, false otherwise + Task VerifySignatureAsync( + byte[] message, + Principal publicKey, + Principal rootPublicKey, + byte[] signature + ); + + /// + /// Gets all PocketIC instances + /// + /// List of all instances and their status + Task> GetInstancesAsync(); + + /// + /// Creates a new PocketIC instance with the specified subnet configuration + /// + /// Optional application subnet configurations. Will create a single application subnet if not specified + /// Optional Bitcoin subnet configuration. Will not create if not specified + /// Optional fiduciary subnet configuration. Will not create if not specified + /// Optional Internet Identity subnet configuration. Will not create if not specified + /// Optional Network Nervous System subnet configuration. Will not create if not specified + /// Optional Service Nervous System subnet configuration. Will not create if not specified + /// Optional system subnet configurations. Will not create if not specified + /// Optional verified application subnet configurations + /// Whether to enable non-mainnet features. Defaults to false + /// A tuple containing the new instance id and topology information + Task<(int Id, List Topology)> CreateInstanceAsync( + List? applicationSubnets = null, + SubnetConfig? bitcoinSubnet = null, + SubnetConfig? fiduciarySubnet = null, + SubnetConfig? iiSubnet = null, + SubnetConfig? nnsSubnet = null, + SubnetConfig? snsSubnet = null, + List? systemSubnets = null, + List? verifiedApplicationSubnets = null, + bool nonmainnetFeatures = false + ); + + /// + /// Deletes a PocketIC instance + /// + /// The id of the instance to delete + Task DeleteInstanceAsync(int id); + + /// + /// Makes a query call to a canister + /// + /// The id of the PocketIC instance + /// The principal making the call + /// The target canister id + /// The method name to call + /// The raw candid request argument + /// Optional effective principal for the call + /// The raw candid response from the canister + Task QueryCallAsync( + int instanceId, + Principal sender, + Principal canisterId, + string method, + CandidArg request, + EffectivePrincipal? effectivePrincipal = null); + + /// + /// Gets the topology information for a PocketIC instance + /// + /// The id of the PocketIC instance + /// List of subnet topologies + Task> GetTopologyAsync(int instanceId); + + /// + /// Gets the current timestamp of a PocketIC instance + /// + /// The id of the PocketIC instance + /// The current timestamp + Task GetTimeAsync(int instanceId); + + /// + /// Gets pending canister HTTP requests + /// + /// The id of the PocketIC instance + /// The pending canister HTTP request + Task GetCanisterHttpAsync(int instanceId); + + /// + /// Gets the cycles balance of a canister + /// + /// The id of the PocketIC instance + /// The canister id + /// The cycles balance of the canister + Task GetCyclesBalanceAsync(int instanceId, Principal canisterId); + + /// + /// Gets the stable memory of a canister + /// + /// The id of the PocketIC instance + /// The canister id + /// The stable memory bytes of the canister + Task GetStableMemoryAsync(int instanceId, Principal canisterId); + + /// + /// Gets the subnet id for a canister + /// + /// The id of the PocketIC instance + /// The canister id + /// The subnet id of the canister + Task GetSubnetIdForCanisterAsync(int instanceId, Principal canisterId); + + /// + /// Gets the public key for a subnet + /// + /// The id of the PocketIC instance + /// The subnet id + /// The public key principal of the subnet + Task GetPublicKeyForSubnetAsync(int instanceId, Principal subnetId); + + /// + /// Submits an ingress message to a canister without waiting for execution + /// + /// The id of the PocketIC instance + /// The principal sending the message + /// The target canister id + /// The method name to call + /// The raw candid request argument + /// Optional effective principal for the call + /// The raw candid response + Task SubmitIngressMessageAsync( + int instanceId, + Principal sender, + Principal canisterId, + string method, + CandidArg request, + EffectivePrincipal? effectivePrincipal = null); + + /// + /// Executes an ingress message on a canister and waits for the response + /// + /// The id of the PocketIC instance + /// The principal sending the message + /// The target canister id + /// The method name to call + /// The raw candid request argument + /// Optional effective principal for the call + /// The raw candid response + Task ExecuteIngressMessageAsync( + int instanceId, + Principal sender, + Principal canisterId, + string method, + CandidArg request, + EffectivePrincipal? effectivePrincipal = null); + + /// + /// Waits for an ingress message to complete execution + /// + /// The id of the PocketIC instance + /// The id of the ingress message + /// Optional effective principal for the call + Task AwaitIngressMessageAsync(int instanceId, byte[] messageId, Principal? effectivePrincipal = null); + + /// + /// Sets the current time of the IC instance + /// + /// The IC instance + /// The new timestamp + Task SetTimeAsync(int instanceId, ICTimestamp timestamp); + + /// + /// Configures automatic time progression for the IC instance + /// + /// The IC instance + /// Optional delay between time updates + Task AutoProgressTimeAsync(int instanceId, TimeSpan? artificialDelay = null); + + /// + /// Stops automatic time progression for the IC instance + /// + /// The IC instance + Task StopProgressTimeAsync(int instanceId); + + /// + /// Adds cycles to a canister + /// + /// The id of the IC instance + /// The canister id + /// The amount of cycles to add + /// The new cycles balance of the canister + Task AddCyclesAsync(int instanceId, Principal canisterId, ulong amount); + + /// + /// Sets the stable memory of a canister + /// + /// The id of the IC instance + /// The canister id + /// The new stable memory bytes + Task SetStableMemoryAsync(int instanceId, Principal canisterId, byte[] memory); + + /// + /// Makes the IC produce and progress by one block + /// + /// The id of the IC instance + Task TickAsync(int instanceId); + + /// + /// Mocks a response to a canister HTTP request + /// + /// The id of the IC instance + /// The id of the HTTP request + /// The subnet id of the canister + /// The response to send + /// Additional responses to send + Task MockCanisterHttpResponseAsync( + int instanceId, + ulong requestId, + Principal subnetId, + CanisterHttpResponse response, + List additionalResponses + ); + + /// + /// Starts an HTTP gateway for handling requests to the IC instance + /// + /// The id of the IC instance + /// Optional port number to listen on + /// Optional list of domains to accept requests from + /// Optional HTTPS configuration + /// The URL of the HTTP gateway + Task StartHttpGatewayAsync(int instanceId, int? port = null, List? domains = null, HttpsConfig? httpsConfig = null); + + /// + /// Stops the HTTP gateway for an IC instance + /// + /// The id of the IC instance + Task StopHttpGatewayAsync(int instanceId); +} + +/// +/// Information about a PocketIC instance +/// +public class Instance +{ + /// + /// The unique identifier for this instance + /// + public required int Id { get; set; } + + /// + /// The current status of this instance + /// + public required InstanceStatus Status { get; set; } +} + +/// +/// The status of a PocketIC instance +/// +public enum InstanceStatus +{ + /// + /// The instance is available for use + /// + Available, + /// + /// The instance has been deleted + /// + Deleted +} + +/// +/// Configuration for HTTPS support +/// +public class HttpsConfig +{ + /// + /// Path to the TLS certificate file + /// + public required string CertPath { get; set; } + + /// + /// Path to the private key file + /// + public required string KeyPath { get; set; } +} + +/// +/// Represents the topology information for a subnet +/// +public class SubnetTopology +{ + /// + /// The subnet's principal id + /// + public required Principal Id { get; set; } + + /// + /// The type of subnet + /// + public required SubnetType Type { get; set; } + + /// + /// The subnet's seed bytes + /// + public required byte[] SubnetSeed { get; set; } + + /// + /// The node ids in this subnet + /// + public required List NodeIds { get; set; } + + /// + /// The canister id ranges for this subnet + /// + public required List CanisterRanges { get; set; } +} + +/// +/// Represents a range of canister ids +/// +public class CanisterRange +{ + /// + /// The start of the canister id range + /// + public required Principal Start { get; set; } + + /// + /// The end of the canister id range + /// + public required Principal End { get; set; } +} + +/// +/// The type of subnet +/// +public enum SubnetType +{ + /// + /// Application subnet + /// + Application, + /// + /// Bitcoin subnet + /// + Bitcoin, + /// + /// Fiduciary subnet + /// + Fiduciary, + /// + /// Internet Identity subnet + /// + InternetIdentity, + /// + /// Network Nervous System subnet + /// + NNS, + /// + /// Social Network System subnet + /// + SNS, + /// + /// System subnet + /// + System +} + +/// +/// Specifies an effective principal for message routing +/// +public class EffectivePrincipal +{ + /// + /// The type of effective principal + /// + public required EffectivePrincipalType Type { get; set; } + + /// + /// The principal id + /// + public required Principal Id { get; set; } +} + +/// +/// Types of effective principals +/// +public enum EffectivePrincipalType +{ + /// + /// Subnet + /// + Subnet, + /// + /// Canister + /// + Canister +} + +/// +/// Configuration for a subnet +/// +public class SubnetConfig +{ + /// + /// Whether to enable deterministic time slicing + /// + public bool? EnableDeterministicTimeSlicing { get; set; } + + /// + /// Whether to enable high instruction limits for benchmarking + /// + public bool? EnableBenchmarkingInstructionLimits { get; set; } + + /// + /// The subnet state configuration + /// + public required SubnetStateConfig State { get; set; } + + /// + /// Helper function to create a new/blank subnet configuration + /// + /// (Optional) If true, will enable DTS. Null value will use the IC default + /// (Optional) If true, will enable benchamrk instruction limits. Null value will use the IC default + /// A config for a new subnet + public static SubnetConfig New(bool? enableDts = null, bool? enableBenchmarkInstructionLimits = null) + { + return new SubnetConfig + { + EnableDeterministicTimeSlicing = enableDts, + EnableBenchmarkingInstructionLimits = enableBenchmarkInstructionLimits, + State = SubnetStateConfig.New() + }; + } + + /// + /// Helper function to create a subnet configuration from an existing state path + /// + /// The filesystem path to the ic_state directory to load data from + /// The id of the subnet being restored + /// (Optional) If true, will enable DTS. Null value will use the IC default + /// (Optional) If true, will enable benchamrk instruction limits. Null value will use the IC default + /// + public static SubnetConfig FromPath(string path, Principal subnetId, bool? enableDts = null, bool? enableBenchmarkInstructionLimits = null) + { + return new SubnetConfig + { + EnableDeterministicTimeSlicing = enableDts, + EnableBenchmarkingInstructionLimits = enableBenchmarkInstructionLimits, + State = SubnetStateConfig.FromPath(path, subnetId) + }; + } +} + +/// +/// Configuration for subnet state initialization +/// +public class SubnetStateConfig +{ + /// + /// The type of state configuration + /// + public SubnetStateType Type { get; private set; } + + /// + /// Path to existing state, if loading from path + /// + public string? Path { get; private set; } + + /// + /// Subnet id if loading existing state + /// + public Principal? SubnetId { get; private set; } + + private SubnetStateConfig(SubnetStateType type, string? path, Principal? subnetId) + { + this.Type = type; + this.Path = path; + this.SubnetId = subnetId; + } + + /// + /// Creates configuration for a new subnet with empty state + /// + public static SubnetStateConfig New() + { + return new SubnetStateConfig(SubnetStateType.New, null, null); + } + + /// + /// Creates configuration for loading existing state from a path + /// + public static SubnetStateConfig FromPath(string path, Principal subnetId) + { + return new SubnetStateConfig(SubnetStateType.FromPath, path, subnetId); + } +} + +/// +/// The type of subnet state configuration +/// +public enum SubnetStateType +{ + /// + /// Create a new subnet with empty state + /// + New, + /// + /// Load existing state from a path + /// + FromPath +} + +/// +/// HTTP request from a canister +/// +public class CanisterHttpRequest +{ + /// + /// The subnet id where the request originated + /// + public required Principal SubnetId { get; set; } + + /// + /// Unique identifier for this request + /// + public required ulong RequestId { get; set; } + + /// + /// The HTTP method for the request + /// + public required CanisterHttpMethod HttpMethod { get; set; } + + /// + /// The target URL for the request + /// + public required string Url { get; set; } + + /// + /// The HTTP headers for the request + /// + public required List Headers { get; set; } + + /// + /// The body of the request + /// + public required byte[] Body { get; set; } + + /// + /// Optional maximum size for the response + /// + public required ulong? MaxResponseBytes { get; set; } +} + +/// +/// HTTP header for canister requests/responses +/// +public class CanisterHttpHeader +{ + /// + /// The header name + /// + public required string Name { get; set; } + + /// + /// The header value + /// + public required string Value { get; set; } +} + +/// +/// HTTP methods supported for canister HTTP calls +/// +public enum CanisterHttpMethod +{ + /// + /// HTTP GET method + /// + Get, + /// + /// HTTP POST method + /// + Post, + /// + /// HTTP HEAD method + /// + Head +} + +/// +/// Base class for HTTP responses to canister HTTP requests +/// +public class CanisterHttpResponse { } + +/// +/// Successful HTTP response to a canister HTTP request +/// +public class CanisterHttpReply : CanisterHttpResponse +{ + /// + /// The HTTP status code + /// + public required ushort Status { get; set; } + + /// + /// The response headers + /// + public required List Headers { get; set; } + + /// + /// The response body + /// + public required byte[] Body { get; set; } +} + +/// +/// Error response to a canister HTTP request +/// +public class CanisterHttpReject : CanisterHttpResponse +{ + /// + /// The reject code indicating the type of error + /// + public required ulong RejectCode { get; set; } + + /// + /// A message describing the error + /// + public required string Message { get; set; } +} \ No newline at end of file diff --git a/src/PocketIC/Models/RequestModels.cs b/src/PocketIC/Models/RequestModels.cs new file mode 100644 index 00000000..641b9b7e --- /dev/null +++ b/src/PocketIC/Models/RequestModels.cs @@ -0,0 +1,160 @@ +using EdjCase.ICP.Candid.Mapping; +using EdjCase.ICP.Candid.Models; + +namespace EdjCase.ICP.PocketIC.Models; + +/// +/// Request model for creating a new canister with optional settings +/// +internal class CreateCanisterRequest +{ + /// + /// Optional canister settings to configure the new canister with + /// + [CandidName("settings")] + public OptionalValue Settings { get; set; } = OptionalValue.NoValue(); + + /// + /// Optional amount of cycles to add to the new canister + /// + [CandidName("amount")] + public OptionalValue Amount { get; set; } = OptionalValue.NoValue(); + + /// + /// Optional specific canister ID to create the canister with + /// + [CandidName("specified_id")] + public OptionalValue SpecifiedId { get; set; } = OptionalValue.NoValue(); +} + +/// +/// Configuration settings for a canister +/// +public class CanisterSettings +{ + /// + /// Optional list of principal IDs that can control this canister + /// + [CandidName("controllers")] + public OptionalValue> Controllers { get; set; } = OptionalValue>.NoValue(); + + /// + /// Optional compute allocation in percentage of subnet capacity + /// + [CandidName("compute_allocation")] + public OptionalValue ComputeAllocation { get; set; } = OptionalValue.NoValue(); + + /// + /// Optional memory allocation in bytes + /// + [CandidName("memory_allocation")] + public OptionalValue MemoryAllocation { get; set; } = OptionalValue.NoValue(); + + /// + /// Optional freezing threshold in seconds + /// + [CandidName("freezing_threshold")] + public OptionalValue FreezingThreshold { get; set; } = OptionalValue.NoValue(); + + /// + /// Optional reserved cycles limit in cycles + /// + [CandidName("reserved_cycles_limit")] + public OptionalValue ReservedCyclesLimit { get; set; } = OptionalValue.NoValue(); +} + +/// +/// Request model for starting a canister +/// +internal class StartCanisterRequest +{ + /// + /// The ID of the canister to start + /// + [CandidName("canister_id")] + public required Principal CanisterId { get; set; } +} + +/// +/// Request model for stopping a canister +/// +internal class StopCanisterRequest +{ + /// + /// The ID of the canister to stop + /// + [CandidName("canister_id")] + public required Principal CanisterId { get; set; } +} + +/// +/// Request model for installing code on a canister +/// +internal class InstallCodeRequest +{ + /// + /// The ID of the target canister + /// + [CandidName("canister_id")] + public required Principal CanisterId { get; set; } + + /// + /// The initialization/upgrade arguments in raw bytes + /// + [CandidName("arg")] + public required byte[] Arg { get; set; } + + /// + /// The WASM module bytes to install + /// + [CandidName("wasm_module")] + public required byte[] WasmModule { get; set; } + + /// + /// The installation mode (install, reinstall, or upgrade) + /// + [CandidName("mode")] + public required InstallCodeMode Mode { get; set; } +} + +/// +/// The mode for installing code on a canister +/// +public enum InstallCodeMode +{ + /// + /// Install new code on an empty canister + /// + [CandidName("install")] + Install, + + /// + /// Replace existing code and clear state + /// + [CandidName("reinstall")] + Reinstall, + + /// + /// Upgrade existing code while preserving state + /// + [CandidName("upgrade")] + Upgrade +} + +/// +/// Request model for updating canister settings +/// +internal class UpdateCanisterSettingsRequest +{ + /// + /// The ID of the canister to update + /// + [CandidName("canister_id")] + public required Principal CanisterId { get; set; } + + /// + /// The new settings to apply to the canister + /// + [CandidName("settings")] + public required CanisterSettings Settings { get; set; } +} \ No newline at end of file diff --git a/src/PocketIC/Models/ResponseModels.cs b/src/PocketIC/Models/ResponseModels.cs new file mode 100644 index 00000000..37bb3c2d --- /dev/null +++ b/src/PocketIC/Models/ResponseModels.cs @@ -0,0 +1,20 @@ +using EdjCase.ICP.Candid.Mapping; +using EdjCase.ICP.Candid.Models; + +namespace EdjCase.ICP.PocketIC.Models; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + +/// +/// Response model returned when creating a new canister +/// +public class CreateCanisterResponse +{ + /// + /// The principal ID of the newly created canister + /// + [CandidName("canister_id")] + public Principal CanisterId { get; set; } +} + +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. \ No newline at end of file diff --git a/src/PocketIC/PocketIc.cs b/src/PocketIC/PocketIc.cs new file mode 100644 index 00000000..b13771a1 --- /dev/null +++ b/src/PocketIC/PocketIc.cs @@ -0,0 +1,903 @@ +using EdjCase.ICP.Agent.Agents; +using EdjCase.ICP.Agent.Identities; +using EdjCase.ICP.Candid; +using EdjCase.ICP.Candid.Models; +using EdjCase.ICP.PocketIC.Client; +using EdjCase.ICP.PocketIC.Models; + +namespace EdjCase.ICP.PocketIC +{ + /// + /// The main interface for interacting with a PocketIC instance. + /// PocketIC is a local canister smart contract testing platform for the Internet Computer. + /// + public class PocketIc : IAsyncDisposable + { + private static readonly Principal MANAGEMENT_CANISTER_ID = Principal.FromText("aaaaa-aa"); + + /// + /// The REST HTTP client for making requests to the PocketIC server + /// + public IPocketIcHttpClient HttpClient { get; } + /// + /// The unique identifier for this PocketIC instance + /// + public int InstanceId { get; } + + private readonly CandidConverter candidConverter; + private List? topologyCache; + + private PocketIc( + IPocketIcHttpClient client, + int instanceId, + List? topology = null, + CandidConverter? candidConverter = null + ) + { + this.HttpClient = client; + this.InstanceId = instanceId; + this.candidConverter = candidConverter ?? CandidConverter.Default; + this.topologyCache = topology; + } + + /// + /// Creates and installs a new canister with the provided WASM module and initialization arguments. + /// + /// The WASM module bytes to install + /// The initialization arguments in candid format + /// Optional canister settings + /// Optional amount of cycles to add to the canister + /// Optional specific canister ID to use + /// The Principal ID of the created canister + public async Task CreateAndInstallCanisterAsync( + byte[] wasmModule, + CandidArg arg, // TODO can we take in a generic arg type? but issue is there can be multiple args + CanisterSettings? settings = null, + UnboundedUInt? cyclesAmount = null, + Principal? specifiedId = null + ) + { + CreateCanisterResponse createCanisterResponse = await this.CreateCanisterAsync( + settings: settings, + cyclesAmount: cyclesAmount, + specifiedId: specifiedId + ); + await this.InstallCodeAsync( + canisterId: createCanisterResponse.CanisterId, + wasmModule: wasmModule, + arg: arg, + mode: InstallCodeMode.Install + ); + return createCanisterResponse.CanisterId; + } + + /// + /// Creates a new canister with optional settings + /// + /// Optional canister settings + /// Optional amount of cycles to add + /// Optional specific canister ID to use + /// The response containing the created canister's info + public async Task CreateCanisterAsync( + CanisterSettings? settings = null, + UnboundedUInt? cyclesAmount = null, + Principal? specifiedId = null + ) + { + var request = new CreateCanisterRequest + { + Settings = settings == null ? OptionalValue.NoValue() : OptionalValue.WithValue(settings), + Amount = cyclesAmount == null ? OptionalValue.NoValue() : OptionalValue.WithValue(cyclesAmount), + SpecifiedId = specifiedId == null ? OptionalValue.NoValue() : OptionalValue.WithValue(specifiedId) + }; + return await this.UpdateCallAsync( + Principal.Anonymous(), + MANAGEMENT_CANISTER_ID, + "provisional_create_canister_with_cycles", + request + ); + } + + /// + /// Starts an idle canister + /// + /// The ID of the canister to start + public async Task StartCanisterAsync(Principal canisterId) + { + StartCanisterRequest request = new() { CanisterId = canisterId }; + await this.UpdateCallNoResponseAsync( + Principal.Anonymous(), + MANAGEMENT_CANISTER_ID, + "start_canister", + request + ); + } + + /// + /// Stops a running canister + /// + /// The ID of the canister to stop + public async Task StopCanisterAsync(Principal canisterId) + { + StopCanisterRequest request = new() { CanisterId = canisterId }; + + await this.UpdateCallNoResponseAsync( + Principal.Anonymous(), + MANAGEMENT_CANISTER_ID, + "stop_canister", + request + ); + } + + /// + /// Installs WASM code on a canister + /// + /// The target canister ID + /// The WASM module bytes to install + /// The installation arguments in candid format + /// The installation mode (install, upgrade, reinstall) + public async Task InstallCodeAsync( + Principal canisterId, + byte[] wasmModule, + CandidArg arg, + InstallCodeMode mode + ) + { + InstallCodeRequest request = new() + { + CanisterId = canisterId, + Arg = arg.Encode(), + WasmModule = wasmModule, + Mode = mode + }; + await this.UpdateCallNoResponseAsync( + Principal.Anonymous(), + MANAGEMENT_CANISTER_ID, + "install_code", + request + ); + } + + /// + /// Creates and starts an HTTP gateway for this PocketIC instance for handling HTTP requests. + /// The gateway will expose an API endpoint for making HTTP requests to the IC instance. + /// When disposed, the gateway will be stopped. + /// NOTE: The gateway requires an NNS subnet to be running in the IC instance. + /// + /// Optional port number to listen on. If not specified, will choose an available port + /// Optional list of domains the gateway should accept requests from. Defaults to localhost if not specified + /// Optional HTTPS configuration if TLS support is needed + /// A disposable HttpGateway object that represents the running gateway and provides access to the gateway URL + public async Task RunHttpGatewayAsync(int? port = null, List? domains = null, HttpsConfig? httpsConfig = null) + { + Uri instanceUri = this.HttpClient.GetServerUrl(); + Uri uri = await this.HttpClient.StartHttpGatewayAsync(this.InstanceId, port: port, domains: domains, httpsConfig: httpsConfig); + + return new HttpGateway(uri, async () => await this.HttpClient.StopHttpGatewayAsync(this.InstanceId)); + } + + /// + /// Makes the IC produce and progress by one or more blocks + /// + /// Number of ticks to execute + public async Task TickAsync(int times = 1) + { + for (int i = 0; i < times; i++) + { + await this.HttpClient.TickAsync(this.InstanceId); + } + } + + /// + /// Gets the current time of the IC + /// + /// The current IC timestamp + public Task GetTimeAsync() + { + return this.HttpClient.GetTimeAsync(this.InstanceId); + } + + /// + /// Sets the current time of the IC + /// + /// The timestamp to set + public Task SetTimeAsync(ICTimestamp time) + { + return this.HttpClient.SetTimeAsync(this.InstanceId, time); + } + + /// + /// Enables automatic time progression for the IC instance until disposed + /// + /// Optional delay between time updates + /// A disposable object that will stop auto progression when disposed + public async Task AutoProgressTimeAsync(TimeSpan? artificalDelay = null) + { + await this.HttpClient.AutoProgressTimeAsync(this.InstanceId, artificalDelay); + + return new AutoProgressionDisposable(() => this.HttpClient.StopProgressTimeAsync(this.InstanceId)); + } + + /// + /// Gets the public key for the given subnet + /// + /// The subnet id to look up + /// The subnet public key principal + public Task GetPublicKeyForSubnetAsync(Principal subnetId) + { + return this.HttpClient.GetPublicKeyForSubnetAsync(this.InstanceId, subnetId); + } + + /// + /// Gets the subnet Id for a given canister + /// + /// The canister Id to look up + /// The subnet principal where the canister is hosted + public Task GetSubnetIdForCanisterAsync(Principal canisterId) + { + return this.HttpClient.GetSubnetIdForCanisterAsync(this.InstanceId, canisterId); + } + + /// + /// Gets the topology information for all subnets in this IC instance + /// + /// Whether to use cached topology info. Defaults to true + /// List of subnet topology information + public async ValueTask> GetTopologyAsync(bool useCache = true) + { + List? topologies = null; + if (useCache) + { + topologies = this.topologyCache; + } + if (topologies == null) + { + topologies = await this.HttpClient.GetTopologyAsync(this.InstanceId); + this.topologyCache = topologies; + } + return topologies; + } + + /// + /// Gets the cycles balance of a canister + /// + /// The canister to check + /// The cycles balance + public Task GetCyclesBalanceAsync(Principal canisterId) + { + return this.HttpClient.GetCyclesBalanceAsync(this.InstanceId, canisterId); + } + + /// + /// Adds cycles to a canister + /// + /// The target canister ID + /// The amount of cycles to add + /// The new cycles balance + public Task AddCyclesAsync(Principal canisterId, ulong amount) + { + return this.HttpClient.AddCyclesAsync(this.InstanceId, canisterId, amount); + } + + /// + /// Sets the stable memory contents of a canister + /// + /// The target canister + /// The stable memory bytes to set + public Task SetStableMemoryAsync(Principal canisterId, byte[] stableMemory) + { + return this.HttpClient.SetStableMemoryAsync(this.InstanceId, canisterId, stableMemory); + } + + /// + /// Gets the stable memory contents of a canister + /// + /// The canister to read from + /// The stable memory bytes + public Task GetStableMemoryAsync(Principal canisterId) + { + return this.HttpClient.GetStableMemoryAsync(this.InstanceId, canisterId); + } + + + /// + /// Executes a query call on a canister with no arguments + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// Optional effective principal for the call + /// The query response decoded as type TResponse + public async Task QueryCallAsync( + Principal sender, + Principal canisterId, + string method, + EffectivePrincipal? effectivePrincipal = null + ) + { + CandidArg arg = CandidArg.FromCandid(); + CandidArg responseArg = await this.QueryCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes a query call on a canister with a single argument + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// Optional effective principal for the call + /// The query response decoded as type TResponse + public async Task QueryCallAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1) + ); + CandidArg responseArg = await this.QueryCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes a query call on a canister with two arguments + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// The second candid argument for the call + /// Optional effective principal for the call + /// The query response decoded as type TResponse + public async Task QueryCallAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + T2 p2, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + where T2 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1), + this.candidConverter.FromTypedObject(p2) + ); + CandidArg responseArg = await this.QueryCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes a query call on a canister with three arguments + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// The second candid argument for the call + /// The third candid argument for the call + /// Optional effective principal for the call + /// The query response decoded as type TResponse + public async Task QueryCallAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + T2 p2, + T3 p3, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + where T2 : notnull + where T3 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1), + this.candidConverter.FromTypedObject(p2), + this.candidConverter.FromTypedObject(p3) + ); + CandidArg responseArg = await this.QueryCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes a query call on a canister with a raw CandidArg + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The raw candid argument for the call + /// Optional effective principal for the call + /// The query response decoded as type TResponse + public async Task QueryCallRawAsync( + Principal sender, + Principal canisterId, + string method, + CandidArg arg, + EffectivePrincipal? effectivePrincipal = null + ) + { + return await this.HttpClient.QueryCallAsync( + this.InstanceId, + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + } + + /// + /// Executes an update call on a canister with no arguments + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// Optional effective principal for the call + /// The update response decoded as type TResponse + public async Task UpdateCallAsync( + Principal sender, + Principal canisterId, + string method, + EffectivePrincipal? effectivePrincipal = null + ) + { + CandidArg arg = CandidArg.FromCandid(); + CandidArg responseArg = await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes an update call on a canister with a single argument + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// Optional effective principal for the call + /// The update response decoded as type TResponse + public async Task UpdateCallAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1) + ); + CandidArg responseArg = await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes an update call on a canister with two arguments + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// The second candid argument for the call + /// Optional effective principal for the call + /// The update response decoded as type TResponse + public async Task UpdateCallAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + T2 p2, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + where T2 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1), + this.candidConverter.FromTypedObject(p2) + ); + CandidArg responseArg = await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes an update call on a canister with three arguments + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// The second candid argument for the call + /// The third candid argument for the call + /// Optional effective principal for the call + /// The update response decoded as type TResponse + public async Task UpdateCallAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + T2 p2, + T3 p3, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + where T2 : notnull + where T3 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1), + this.candidConverter.FromTypedObject(p2), + this.candidConverter.FromTypedObject(p3) + ); + CandidArg responseArg = await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + return responseArg.ToObjects(this.candidConverter); + } + + /// + /// Executes an update call on a canister with no arguments and no response + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// Optional effective principal for the call + /// + public async Task UpdateCallNoResponseAsync( + Principal sender, + Principal canisterId, + string method, + EffectivePrincipal? effectivePrincipal = null + ) + { + CandidArg arg = CandidArg.FromCandid(); + await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + } + + /// + /// Executes an update call on a canister with a single argument and no response + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// Optional effective principal for the call + /// + public async Task UpdateCallNoResponseAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1) + ); + await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + } + + /// + /// Executes an update call on a canister with a two arguments and no response + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// The second candid argument for the call + /// Optional effective principal for the call + /// + public async Task UpdateCallNoResponseAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + T2 p2, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + where T2 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1), + this.candidConverter.FromTypedObject(p2) + ); + await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + } + + /// + /// Executes an update call on a canister with a three arguments and no response + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The first candid argument for the call + /// The second candid argument for the call + /// The third candid argument for the call + /// Optional effective principal for the call + /// + public async Task UpdateCallNoResponseAsync( + Principal sender, + Principal canisterId, + string method, + T1 p1, + T2 p2, + T3 p3, + EffectivePrincipal? effectivePrincipal = null + ) + where T1 : notnull + where T2 : notnull + where T3 : notnull + { + CandidArg arg = CandidArg.FromCandid( + this.candidConverter.FromTypedObject(p1), + this.candidConverter.FromTypedObject(p2), + this.candidConverter.FromTypedObject(p3) + ); + await this.UpdateCallRawAsync( + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + } + + + /// + /// Executes an update call on a canister with a raw CandidArg and raw CandidArg response + /// + /// The principal making the call + /// The target canister ID + /// The method name to call + /// The raw candid argument for the call + /// Optional effective principal for the call + /// A raw candid argument from the response + public async Task UpdateCallRawAsync( + Principal sender, + Principal canisterId, + string method, + CandidArg arg, + EffectivePrincipal? effectivePrincipal = null + ) + { + return await this.HttpClient.ExecuteIngressMessageAsync( + this.InstanceId, + sender, + canisterId, + method, + arg, + effectivePrincipal + ); + } + + /// + /// Disposes of the PocketIC instance by deleting the instance + /// + /// + public async ValueTask DisposeAsync() + { + await this.HttpClient.DeleteInstanceAsync(this.InstanceId); + } + + /// + /// Creates a new PocketIC instance using an IPocketIcHttpClient instance + /// + /// The HTTP client to use + /// Optional application subnet configurations. Will create a single application subnet if not specified + /// Optional Bitcoin subnet configuration. Will not create if not specified + /// Optional fiduciary subnet configuration. Will not create if not specified + /// Optional Internet Identity subnet configuration. Will not create if not specified + /// Optional Network Nervous System subnet configuration. Will not create if not specified + /// Optional Service Nervous System subnet configuration. Will not create if not specified + /// Optional system subnet configurations. Will not create if not specified + /// Optional verified application subnet configurations + /// Whether to enable non-mainnet features. Defaults to false + /// Optional candid converter to use, otherwise will use the default + /// A new PocketIC instance + public static async Task CreateAsync( + IPocketIcHttpClient httpClient, + List? applicationSubnets = null, + SubnetConfig? bitcoinSubnet = null, + SubnetConfig? fiduciarySubnet = null, + SubnetConfig? iiSubnet = null, + SubnetConfig? nnsSubnet = null, + SubnetConfig? snsSubnet = null, + List? systemSubnets = null, + List? verifiedApplicationSubnets = null, + bool nonmainnetFeatures = false, + CandidConverter? candidConverter = null + ) + { + (int instanceId, List topology) = await httpClient.CreateInstanceAsync( + applicationSubnets, + bitcoinSubnet, + fiduciarySubnet, + iiSubnet, + nnsSubnet, + snsSubnet, + systemSubnets, + verifiedApplicationSubnets, + nonmainnetFeatures + ); + + return new PocketIc(httpClient, instanceId, topology, candidConverter); + } + + /// + /// Creates a new PocketIC instance + /// + /// The PocketIC server url + /// Optional application subnet configurations. Will create a single application subnet if not specified + /// Optional Bitcoin subnet configuration. Will not create if not specified + /// Optional fiduciary subnet configuration. Will not create if not specified + /// Optional Internet Identity subnet configuration. Will not create if not specified + /// Optional Network Nervous System subnet configuration. Will not create if not specified + /// Optional Service Nervous System subnet configuration. Will not create if not specified + /// Optional system subnet configurations. Will not create if not specified + /// Optional verified application subnet configurations + /// Whether to enable non-mainnet features. Defaults to false + /// Optional candid converter to use, otherwise will use the default + /// Optional request timeout for http requests. Defaults to 30 seconds + /// A new PocketIC instance + public static async Task CreateAsync( + string url, + List? applicationSubnets = null, + SubnetConfig? bitcoinSubnet = null, + SubnetConfig? fiduciarySubnet = null, + SubnetConfig? iiSubnet = null, + SubnetConfig? nnsSubnet = null, + SubnetConfig? snsSubnet = null, + List? systemSubnets = null, + List? verifiedApplicationSubnets = null, + bool nonmainnetFeatures = false, + CandidConverter? candidConverter = null, + TimeSpan? requestTimeout = null + ) + { + IPocketIcHttpClient httpClient = new PocketIcHttpClient(new HttpClient(), url, requestTimeout ?? TimeSpan.FromSeconds(30)); + return await PocketIc.CreateAsync( + httpClient, + applicationSubnets, + bitcoinSubnet, + fiduciarySubnet, + iiSubnet, + nnsSubnet, + snsSubnet, + systemSubnets, + verifiedApplicationSubnets, + nonmainnetFeatures, + candidConverter + ); + } + + } + + /// + /// Represents an HTTP gateway for accessing the Internet Computer. Disposing of this object will stop the gateway + /// + public class HttpGateway : IAsyncDisposable + { + /// + /// The URL of the HTTP gateway + /// + public Uri Url { get; } + private readonly Func disposeTask; + + internal HttpGateway(Uri url, Func disposeTask) + { + this.Url = url; + this.disposeTask = disposeTask; + } + + /// + /// Creates a new HTTP agent configured to use this gateway + /// + /// Optional identity to use for the agent, otherwise will use anonymous identity + /// A configured HTTP agent + public HttpAgent BuildHttpAgent(IIdentity? identity = null) + { + return new HttpAgent( + identity: identity, + httpBoundryNodeUrl: this.Url + ); + } + + /// + /// Disposes of the HTTP gateway + /// + public async ValueTask DisposeAsync() + { + await this.disposeTask(); + } + } + + internal class AutoProgressionDisposable : IAsyncDisposable + { + private readonly Func disposeTask; + + internal AutoProgressionDisposable(Func disposeTask) + { + this.disposeTask = disposeTask; + } + + public async ValueTask DisposeAsync() + { + await this.disposeTask(); + } + } + +} \ No newline at end of file diff --git a/src/PocketIC/PocketIcHttpClient.cs b/src/PocketIC/PocketIcHttpClient.cs new file mode 100644 index 00000000..52909143 --- /dev/null +++ b/src/PocketIC/PocketIcHttpClient.cs @@ -0,0 +1,847 @@ +using System.Text; +using System.Text.Json; +using EdjCase.ICP.Candid.Models; +using System.Text.Json.Nodes; +using System.Net; +using System.Diagnostics; + +namespace EdjCase.ICP.PocketIC.Client; + +/// +/// The default implementation of the interface. +/// +public class PocketIcHttpClient : IPocketIcHttpClient +{ + private readonly HttpClient httpClient; + private readonly string baseUrl; + private const int POLLING_PERIOD_MS = 10; + private readonly TimeSpan requestTimeout; + + internal PocketIcHttpClient( + HttpClient httpClient, + string url, + TimeSpan requestTimeout + ) + { + this.httpClient = httpClient; + this.baseUrl = url; + this.requestTimeout = requestTimeout; + } + + /// + public Uri GetServerUrl() + { + return new Uri(this.baseUrl); + } + + /// + public async Task UploadBlobAsync(byte[] blob) + { + var content = new ByteArrayContent(blob); + HttpResponseMessage response = await this.httpClient.PostAsync($"{this.baseUrl}/blobstore", content); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + /// + public async Task DownloadBlobAsync(string blobId) + { + HttpResponseMessage response = await this.httpClient.GetAsync($"{this.baseUrl}/blobstore/{blobId}"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsByteArrayAsync(); + } + /// + public async Task VerifySignatureAsync( + byte[] message, + Principal publicKey, + Principal rootPublicKey, + byte[] signature + ) + { + // Doesn't use the ApiResponse pattern + var request = new JsonObject + { + ["msg"] = JsonValue.Create(message), + ["pubkey"] = JsonValue.Create(publicKey.Raw), + ["root_pubkey"] = JsonValue.Create(rootPublicKey.Raw), + ["sig"] = JsonValue.Create(signature), + }; + HttpResponseMessage response = await this.MakeHttpRequestAsync(HttpMethod.Post, "/verify_signature", request); + Stream stream = await response.Content.ReadAsStreamAsync(); + JsonNode? node = await JsonNode.ParseAsync(stream); + if (node == null) + { + throw new Exception("There was no json response from the server"); + } + if (node["Err"] != null) + { + Console.WriteLine("Signature failed to verify: " + node["Err"]!.Deserialize()); + return false; + } + + return true; + } + /// + public async Task> GetInstancesAsync() + { + // Doesn't use the ApiResponse pattern + HttpResponseMessage response = await this.MakeHttpRequestAsync(HttpMethod.Get, "/instances"); + Stream stream = await response.Content.ReadAsStreamAsync(); + JsonNode? node = await JsonNode.ParseAsync(stream); + if (node == null) + { + throw new Exception("There was no json response from the server"); + } + return node + !.AsArray() + .Select((s, i) => new Instance { Id = i, Status = Enum.Parse(s!.Deserialize()!) }).ToList(); + } + /// + public async Task<(int Id, List Topology)> CreateInstanceAsync( + List? applicationSubnets = null, + SubnetConfig? bitcoinSubnet = null, + SubnetConfig? fiduciarySubnet = null, + SubnetConfig? iiSubnet = null, + SubnetConfig? nnsSubnet = null, + SubnetConfig? snsSubnet = null, + List? systemSubnets = null, + List? verifiedApplicationSubnets = null, + bool nonmainnetFeatures = false + ) + { + // Default to a single application subnet + applicationSubnets ??= new List + { + SubnetConfig.New() + }; + + JsonNode? MapSubnet(SubnetConfig? subnetConfig) + { + if (subnetConfig == null) + { + return null; + } + JsonNode stateConfig; + switch (subnetConfig.State.Type) + { + case SubnetStateType.New: + stateConfig = JsonValue.Create("New")!; + break; + case SubnetStateType.FromPath: + stateConfig = new JsonObject + { + ["FromPath"] = new JsonArray + { + subnetConfig.State.Path!, + new JsonObject + { + ["subnet_id"] = Convert.ToBase64String(subnetConfig.State.SubnetId!.Raw) + } + } + }; + break; + default: + throw new NotImplementedException(); + } + return new JsonObject + { + ["dts_flag"] = subnetConfig.EnableDeterministicTimeSlicing == false ? "Disabled" : "Enabled", + ["instruction_config"] = subnetConfig.EnableBenchmarkingInstructionLimits == true ? "Benchmarking" : "Production", + ["state_config"] = stateConfig + }; + } + + JsonArray MapSubnets(List? subnets) + { + if (subnets == null) + { + return new JsonArray(); + } + return new JsonArray(subnets.Select(s => MapSubnet(s)).ToArray()); + } + + var request = new JsonObject + { + ["subnet_config_set"] = new JsonObject + { + ["application"] = MapSubnets(applicationSubnets), + ["bitcoin"] = MapSubnet(bitcoinSubnet), + ["fiduciary"] = MapSubnet(fiduciarySubnet), + ["ii"] = MapSubnet(iiSubnet), + ["nns"] = MapSubnet(nnsSubnet), + ["sns"] = MapSubnet(snsSubnet), + ["system"] = MapSubnets(systemSubnets), + ["verified_application"] = MapSubnets(verifiedApplicationSubnets) + }, + ["nonmainnet_features"] = nonmainnetFeatures + }; + // Doesn't use the ApiResponse pattern + HttpResponseMessage response = await this.MakeHttpRequestAsync(HttpMethod.Post, "/instances", request); + Stream stream = await response.Content.ReadAsStreamAsync(); + JsonNode? node = await JsonNode.ParseAsync(stream); + if (node == null) + { + throw new Exception("There was no json response from the server"); + } + + if (node["Error"] != null) + { + string message = node!["error"]!["message"]!.Deserialize()!; + throw new Exception($"Failed to create PocketIC instance: {message}"); + } + JsonObject? created = node["Created"]?.AsObject(); + if (created == null) + { + throw new Exception("Failed to create PocketIC instance, invalid response from server"); + } + + int instanceId = created["instance_id"]!.Deserialize()!; + + List topology = created["topology"] + ?.Deserialize>() + ?.Select(kv => MapSubnetTopology(kv.Key, kv.Value)) + ?.ToList() + ?? []; + return (instanceId, topology); + } + /// + public async Task DeleteInstanceAsync(int id) + { + await this.DeleteAsync($"/instances/{id}"); + } + /// + public async Task QueryCallAsync( + int instanceId, + Principal sender, + Principal canisterId, + string method, + CandidArg request, + EffectivePrincipal? effectivePrincipal = null) + { + return await this.ProcessIngressMessageInternalAsync( + $"/instances/{instanceId}/read/query", + sender, + canisterId, + method, + request, + effectivePrincipal + ); + } + /// + public async Task> GetTopologyAsync(int instanceId) + { + JsonNode? response = await this.GetJsonAsync($"/instances/{instanceId}/read/topology"); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + return response + .AsObject() + ?.Deserialize>() + ?.Select(kv => MapSubnetTopology(kv.Key, kv.Value)) + ?.ToList() + ?? []; + } + /// + public async Task GetTimeAsync(int instanceId) + { + JsonNode? response = await this.GetJsonAsync($"/instances/{instanceId}/read/get_time"); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + return ICTimestamp.FromNanoSeconds(response!["nanos_since_epoch"].Deserialize()!); + } + /// + public async Task GetCanisterHttpAsync(int instanceId) + { + JsonNode? response = await this.GetJsonAsync($"/instances/{instanceId}/read/get_canister_http"); + + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + return new CanisterHttpRequest + { + Body = response!["body"].Deserialize()!, + Headers = response!["headers"]!.AsObject()!.Select(kv => new CanisterHttpHeader + { + Name = kv.Key, + Value = kv.Value.Deserialize()! + }).ToList(), + Url = response!["url"].Deserialize()!, + SubnetId = Principal.FromBytes(response!["subnet_id"].Deserialize()!), + HttpMethod = Enum.Parse(response!["http_method"].Deserialize()!), + MaxResponseBytes = response!["max_response_bytes"].Deserialize()!, + RequestId = response!["request_id"].Deserialize()! + }; + } + /// + public async Task GetCyclesBalanceAsync(int instanceId, Principal canisterId) + { + var request = new JsonObject + { + ["canister_id"] = Convert.ToBase64String(canisterId.Raw) + }; + JsonNode? response = await this.PostJsonAsync($"/instances/{instanceId}/read/get_cycles", request); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + return response!["cycles"].Deserialize()!; + } + /// + public async Task GetStableMemoryAsync(int instanceId, Principal canisterId) + { + var request = new JsonObject + { + ["canister_id"] = Convert.ToBase64String(canisterId.Raw) + }; + JsonNode? response = await this.PostJsonAsync($"/instances/{instanceId}/read/get_stable_memory", request); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + return response!["blob"].Deserialize()!; + } + /// + public async Task GetSubnetIdForCanisterAsync(int instanceId, Principal canisterId) + { + var request = new JsonObject + { + ["canister_id"] = Convert.ToBase64String(canisterId.Raw) + }; + JsonNode? response = await this.PostJsonAsync($"/instances/{instanceId}/read/get_subnet", request); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + byte[] subnetId = response!["subnet_id"].Deserialize()!; + return Principal.FromBytes(subnetId); + } + /// + public async Task GetPublicKeyForSubnetAsync(int instanceId, Principal subnetId) + { + var request = new JsonObject + { + ["subnet_id"] = Convert.ToBase64String(subnetId.Raw) + }; + JsonNode? response = await this.PostJsonAsync($"/instances/{instanceId}/read/pub_key", request); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + byte[] publicKey = response!.AsArray().Select(r => r.Deserialize()!).ToArray(); + return Principal.FromBytes(publicKey); + } + /// + public async Task SubmitIngressMessageAsync( + int instanceId, + Principal sender, + Principal canisterId, + string method, + CandidArg request, + EffectivePrincipal? effectivePrincipal = null) + { + return await this.ProcessIngressMessageInternalAsync( + $"/instances/{instanceId}/update/submit_ingress_message", + sender, + canisterId, + method, + request, + effectivePrincipal + ); + } + /// + public async Task ExecuteIngressMessageAsync( + int instanceId, + Principal sender, + Principal canisterId, + string method, + CandidArg request, + EffectivePrincipal? effectivePrincipal = null) + { + return await this.ProcessIngressMessageInternalAsync( + $"/instances/{instanceId}/update/execute_ingress_message", + sender, + canisterId, + method, + request, + effectivePrincipal + ); + } + + + private async Task ProcessIngressMessageInternalAsync( + string route, + Principal sender, + Principal canisterId, + string method, + CandidArg arg, + EffectivePrincipal? effectivePrincipal = null) + { + byte[] payload = arg.Encode(); + + JsonNode effectivePrincipalJson = effectivePrincipal == null ? + JsonValue.Create("None")! : + new JsonObject + { + [effectivePrincipal.Type == EffectivePrincipalType.Subnet ? "SubnetId" : "CanisterId"] = + Convert.ToBase64String(effectivePrincipal.Id.Raw) + }; + + var options = new JsonObject + { + ["canister_id"] = Convert.ToBase64String(canisterId.Raw), + ["effective_principal"] = effectivePrincipalJson, + ["method"] = method, + ["payload"] = Convert.ToBase64String(payload), + ["sender"] = Convert.ToBase64String(sender.Raw) + }; + JsonNode? response = await this.PostJsonAsync(route, options); + if (response == null) + { + throw new Exception("Failed to get response from canister"); + } + if (response["Err"] != null) + { + string message = response!["Err"]!["description"]!.Deserialize()!; + string code = response!["Err"]!["code"]!.Deserialize()!; + throw new Exception($"Canister returned an error. Code: {code}, Message: {message}"); + } + if (response["Ok"] == null) + { + throw new Exception("Failed to get a valid response from canister. Response: " + response?.ToJsonString()); + } + byte[]? candidBytes = response!["Ok"]!["Reply"]?.Deserialize(); + if (candidBytes == null) + { + throw new Exception("Failed to get a valid response from canister. Response: " + response?.ToJsonString()); + } + return CandidArg.FromBytes(candidBytes); + } + /// + public async Task AwaitIngressMessageAsync(int instanceId, byte[] messageId, Principal? effectivePrincipal = null) + { + var request = new JsonObject + { + ["message_id"] = Convert.ToBase64String(messageId), + ["effective_principal"] = effectivePrincipal == null ? null : Convert.ToBase64String(effectivePrincipal.Raw) + }; + await this.PostJsonAsync($"/instances/{instanceId}/update/await_ingress_message", request); + // TODO + } + /// + public async Task SetTimeAsync(int instanceId, ICTimestamp timestamp) + { + if (!timestamp.NanoSeconds.TryToUInt64(out ulong nanosSinceEpoch)) + { + throw new ArgumentException("Nanoseconds is too large to convert to ulong"); + } + var request = new JsonObject + { + ["nanos_since_epoch"] = nanosSinceEpoch + }; + await this.PostJsonAsync($"/instances/{instanceId}/update/set_time", request); + } + + /// + public async Task AutoProgressTimeAsync(int instanceId, TimeSpan? artificialDelay = null) + { + var request = new JsonObject(); + if (artificialDelay.HasValue) + { + request["artificial_delay_ms"] = JsonValue.Create(artificialDelay.Value.TotalMilliseconds); + } + await this.PostJsonAsync($"/instances/{instanceId}/auto_progress", request); + } + /// + public async Task StopProgressTimeAsync(int instanceId) + { + await this.PostJsonAsync($"/instances/{instanceId}/stop_progress", null); + } + /// + public async Task AddCyclesAsync(int instanceId, Principal canisterId, ulong amount) + { + var request = new JsonObject + { + ["canister_id"] = Convert.ToBase64String(canisterId.Raw), + ["amount"] = amount + }; + JsonNode? response = await this.PostJsonAsync($"/instances/{instanceId}/update/add_cycles", request); + if (response == null) + { + throw new Exception("There was no json response from the server"); + } + return response["cycles"].Deserialize()!; + } + /// + public async Task SetStableMemoryAsync(int instanceId, Principal canisterId, byte[] memory) + { + string blobId = await this.UploadBlobAsync(memory); + var request = new JsonObject + { + ["canister_id"] = Convert.ToBase64String(canisterId.Raw), + ["blob_id"] = JsonValue.Create(Convert.FromHexString(blobId)) + }; + await this.PostJsonAsync($"/instances/{instanceId}/update/set_stable_memory", request); + } + /// + public async Task TickAsync(int instanceId) + { + await this.PostJsonAsync($"/instances/{instanceId}/update/tick", null); + } + /// + public async Task MockCanisterHttpResponseAsync( + int instanceId, + ulong requestId, + Principal subnetId, + CanisterHttpResponse response, + List additionalResponses + ) + { + var request = new JsonObject + { + ["request_id"] = JsonValue.Create(requestId), + ["subnet_id"] = JsonValue.Create(subnetId.Raw), + ["response"] = PocketIcHttpClient.SerializeCanisterHttpResponse(response), + ["additional_responses"] = JsonValue.Create( + additionalResponses + .Select(r => PocketIcHttpClient.SerializeCanisterHttpResponse(r)) + .ToArray() + ) + }; + await this.PostJsonAsync($"/instances/{instanceId}/update/mock_canister_http", request); + } + /// + public async Task StartHttpGatewayAsync( + int instanceId, + int? port = null, + List? domains = null, + HttpsConfig? httpsConfig = null + ) + { + var request = new JsonObject + { + ["forward_to"] = new JsonObject + { + ["PocketIcInstance"] = JsonValue.Create(instanceId) + } + }; + if (port != null) + { + request["port"] = JsonValue.Create(port.Value); + } + if (domains != null) + { + request["domains"] = new JsonArray(domains.Select(d => JsonValue.Create(d)).ToArray()); + } + if (httpsConfig != null) + { + request["https_config"] = new JsonObject + { + ["cert_path"] = httpsConfig.CertPath, + ["key_path"] = httpsConfig.KeyPath + }; + } + HttpResponseMessage response = await this.MakeHttpRequestAsync(HttpMethod.Post, "/http_gateway", request); + response.EnsureSuccessStatusCode(); + Stream stream = await response.Content.ReadAsStreamAsync(); + JsonNode? node = await JsonNode.ParseAsync(stream); + if (node == null) + { + throw new Exception("There was no json response from the server"); + } + if (node["Error"] != null) + { + string message = node!["Error"]!["message"]!.Deserialize()!; + throw new Exception($"Failed to start HTTP gateway: {message}"); + } + + + int actualPort = node["Created"]!["port"].Deserialize()!; + string protocol = httpsConfig != null ? "https" : "http"; + string domain = domains?.Any() == true ? domains.First() : "localhost"; + string url = $"{protocol}://{domain}:{actualPort}/"; + return new Uri(url); + } + /// + public async Task StopHttpGatewayAsync(int instanceId) + { + HttpResponseMessage response = await this.MakeHttpRequestAsync(HttpMethod.Post, + $"/http_gateway/{instanceId}/stop" + ); + response.EnsureSuccessStatusCode(); + } + + + // ======================================= + + private static JsonObject SerializeCanisterHttpResponse(CanisterHttpResponse response) + { + if (response is CanisterHttpReply reply) + { + return new JsonObject + { + ["CanisterHttpReply"] = new JsonObject + { + ["status"] = JsonValue.Create(reply.Status), + ["headers"] = JsonValue.Create(reply.Headers.Select(h => new { name = h.Name, value = h.Value }).ToArray()), + ["body"] = JsonValue.Create(reply.Body) + } + }; + } + else if (response is CanisterHttpReject reject) + { + return new JsonObject + { + ["CanisterHttpReject"] = new JsonObject + { + ["reject_code"] = JsonValue.Create(reject.RejectCode), + ["message"] = reject.Message + } + }; + } + throw new ArgumentException("Unknown CanisterHttpResponse type"); + } + + private async Task DeleteAsync(string endpoint) + { + return await this.MakeJsonRequestAsync(HttpMethod.Delete, endpoint); + } + + private async Task GetJsonAsync(string endpoint) + { + return await this.MakeJsonRequestAsync(HttpMethod.Get, endpoint); + } + + private async Task PostJsonAsync(string endpoint, JsonObject? data = null) + { + return await this.MakeJsonRequestAsync(HttpMethod.Post, endpoint, data); + } + + private async Task MakeJsonRequestAsync( + HttpMethod method, + string endpoint, + JsonObject? data = null + ) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (true) + { + ApiResponse response = await this.MakeApiRequestAsync(method, endpoint, data); + switch (response.Type) + { + case ApiResponseType.Error: + throw new Exception(response.AsError()); + case ApiResponseType.Success: + return response.AsSuccess(); + case ApiResponseType.Started: + { + (string stateLabel, string opId) = response.AsStartedOrBusy(); + return await this.WaitForRequestAsync(stateLabel, opId, stopwatch); + } + case ApiResponseType.Busy: + { + (string stateLabel, string opId) = response.AsStartedOrBusy(); + Console.WriteLine($"Instance is busy. state_label: {stateLabel}, op_id: {opId}"); + break; + } + default: + throw new Exception("Unexpected response type: " + response.Type); + } + await Task.Delay(POLLING_PERIOD_MS); + } + } + + private async Task WaitForRequestAsync( + string stateLabel, + string opId, + Stopwatch stopwatch + ) + { + while (true) + { + await Task.Delay(POLLING_PERIOD_MS); + ApiResponse response = await this.MakeApiRequestAsync(HttpMethod.Get, $"/read_graph/{stateLabel}/{opId}"); + switch (response.Type) + { + case ApiResponseType.Error: + Console.WriteLine($"Polling failure, trying again. Error: {response.AsError()}"); + break; + case ApiResponseType.Success: + return response.AsSuccess(); + case ApiResponseType.Started: + Console.WriteLine($"Unexpected 'started' response while polling, trying again. state_label: {stateLabel}, op_id: {opId}"); + break; + case ApiResponseType.Busy: + Console.WriteLine($"Unexpected 'started' response while polling, trying again. state_label: {stateLabel}, op_id: {opId}"); + break; + default: + throw new Exception("Unexpected response type: " + response.Type); + } + if (this.requestTimeout > TimeSpan.Zero && stopwatch.Elapsed > this.requestTimeout) + { + throw new Exception("Request timed out while waiting for completion"); + } + } + } + + private async Task MakeHttpRequestAsync(HttpMethod method, string endpoint, JsonObject? data = null) + { + HttpContent? content; + switch (method.Method) + { + case "GET": + case "DELETE": + content = null; + if (data != null) + { + throw new ArgumentException("GET and DELETE requests cannot have a body"); + } + break; + case "POST": + var json = data?.ToJsonString() ?? ""; + content = new StringContent(json, Encoding.UTF8, "application/json"); + break; + default: + throw new Exception($"Unsupported HTTP method: {method}"); + } + string url = $"{this.baseUrl}{endpoint}"; + var request = new HttpRequestMessage(method, url) + { + Content = content + }; + return await this.httpClient.SendAsync(request); + } + + private async Task> MakeApiRequestAsync( + HttpMethod method, + string endpoint, + JsonObject? data = null + ) + { + HttpResponseMessage response = await this.MakeHttpRequestAsync(method, endpoint, data); + Stream stream = await response.Content.ReadAsStreamAsync(); + JsonNode? node = null; + if (stream.Length > 0) + { + node = await JsonNode.ParseAsync(stream); + } + switch (response.StatusCode) + { + case HttpStatusCode.OK: + { + return new ApiResponse(ApiResponseType.Success, node); + } + case HttpStatusCode.Accepted: + case HttpStatusCode.Conflict: + { + if (node == null) + { + throw new Exception("There was no json response from the server"); + } + string stateLabel = node["state_label"].Deserialize()!; + string opId = node["op_id"].Deserialize()!; + ApiResponseType type = response.StatusCode == HttpStatusCode.Accepted + ? ApiResponseType.Started + : ApiResponseType.Busy; + return new ApiResponse(type, (stateLabel, opId)); + } + default: + { + if (node == null) + { + throw new Exception("There was no json response from the server"); + } + string message = node["message"].Deserialize()!; + return new ApiResponse(ApiResponseType.Error, message); + } + } + } + + + private static SubnetTopology MapSubnetTopology(string subnetId, JsonNode value) + { + string? subnetTypeString = value["subnet_kind"]?.Deserialize(); + if (subnetTypeString == null || !Enum.TryParse(subnetTypeString, out var subnetType)) + { + throw new Exception($"Invalid subnet type: {subnetTypeString}"); + } + + byte[] subnetSeed = value["subnet_seed"] + ?.AsArray() + .Select(b => b.Deserialize()) + .ToArray() + ?? throw new Exception("Subnet seed is missing or invalid"); + + List nodeIds = value["node_ids"] + ?.AsArray() + .Select(id => + { + byte[]? nodeId = id!["node_id"]?.Deserialize() ?? throw new Exception("Node ID is missing or invalid"); + return nodeId; + }) + .ToList() + ?? []; + + Principal MapCanisterRangeValue(JsonNode? value) + { + byte[] canisterIdBytes = value?["canister_id"]?.Deserialize() ?? throw new Exception("Canister range value is missing or invalid"); + return Principal.FromBytes(canisterIdBytes); + } + + List canisterRanges = value["canister_ranges"] + ?.AsArray() + .Select(r => new CanisterRange + { + Start = MapCanisterRangeValue(r?["start"]), + End = MapCanisterRangeValue(r?["end"]) + }) + .ToList() + ?? []; + + return new SubnetTopology + { + Id = Principal.FromText(subnetId), + Type = subnetType, + SubnetSeed = subnetSeed, + NodeIds = nodeIds, + CanisterRanges = canisterRanges + }; + } + + internal class ApiResponse + { + public ApiResponseType Type { get; } + public object? Data { get; } + + public ApiResponse(ApiResponseType type, object? data) + { + this.Type = type; + this.Data = data; + } + + public T AsSuccess() + { + return (T)this.Data!; + } + + public string AsError() + { + return (string)this.Data!; + } + + public (string StateLabel, string OpId) AsStartedOrBusy() + { + return ((string, string))this.Data!; + } + } + + internal enum ApiResponseType + { + Error, + Success, + Started, + Busy + } +} diff --git a/src/PocketIC/PocketIcServer.cs b/src/PocketIC/PocketIcServer.cs new file mode 100644 index 00000000..fb68f4f1 --- /dev/null +++ b/src/PocketIC/PocketIcServer.cs @@ -0,0 +1,224 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace EdjCase.ICP.PocketIC +{ + /// + /// A class to help start the pocket-ic server process + /// + public class PocketIcServer : IAsyncDisposable + { + private readonly Process _serverProcess; + private readonly int _port; + + private PocketIcServer(Process serverProcess, int port) + { + this._serverProcess = serverProcess; + this._port = port; + } + + /// + /// Gets the URL of the server + /// + public string GetUrl() => $"http://127.0.0.1:{this._port}"; + + /// + /// Stops the server process + /// + public async ValueTask StopAsync() + { + if (!this._serverProcess.HasExited) + { + this._serverProcess.Kill(); + await this._serverProcess.WaitForExitAsync(); + } + } + + /// + /// Disposes of the server process + /// + public async ValueTask DisposeAsync() + { + await this.StopAsync(); + this._serverProcess.Dispose(); + } + + + /// + /// Starts the pocket-ic server process + /// + /// Outputs the runtime logs using Debug.WriteLine(...) + /// Outputs the error logs using Debug.WriteLine(...) + /// The instance of the PocketIcServer with the running process + public static async Task Start( + bool showRuntimeLogs = false, + bool showErrorLogs = true + ) + { + string binPath = GetBinPath(); + EnsureExecutablePermission(binPath); + + int pid = Process.GetCurrentProcess().Id; + string picFilePrefix = $"pocket_ic_{pid}"; + string portFilePath = Path.Combine(Path.GetTempPath(), $"{picFilePrefix}.port"); + + var startInfo = new ProcessStartInfo + { + FileName = binPath, + Arguments = $"--pid {pid}", + RedirectStandardOutput = showRuntimeLogs, + RedirectStandardError = showErrorLogs, + UseShellExecute = false, + }; + + Process? serverProcess = Process.Start(startInfo); + + if (serverProcess == null) + { + throw new Exception("Failed to start PocketIC server process"); + } + if (showRuntimeLogs) + { + serverProcess.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + Debug.WriteLine(e.Data); + } + }; + serverProcess.BeginOutputReadLine(); + } + + if (showErrorLogs) + { + + serverProcess.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + Debug.WriteLine(e.Data); + } + }; + serverProcess.BeginErrorReadLine(); + } + + TimeSpan interval = TimeSpan.FromMilliseconds(20); + TimeSpan timeout = TimeSpan.FromSeconds(30); + Stopwatch stopwatch = Stopwatch.StartNew(); + int port = -1; + while (true) + { + try + { + string portString = await File.ReadAllTextAsync(portFilePath); + if (int.TryParse(portString, out port)) + { + break; + } + } + catch (Exception) + { + + } + if (stopwatch.Elapsed > timeout) + { + break; + } + await Task.Delay(interval); // wait to try again + } + if (port == -1) + { + throw new Exception($"Failed to start PocketIC server after {timeout}"); + } + + return new PocketIcServer(serverProcess, port); + } + + private static string GetBinPath() + { + string fileName = "pocket-ic"; + string? ridFolder = null; + + if (RuntimeInformation.OSArchitecture == Architecture.X64) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + ridFolder = "linux-x64"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + ridFolder = "osx-x64"; + } + } + if (ridFolder == null) + { + throw new PlatformNotSupportedException($"Unsupported operating system/architecture: {RuntimeInformation.RuntimeIdentifier}. Supported: linux-x64, osx-64"); + } + + + // Check environment variable first + string? envPath = Environment.GetEnvironmentVariable("POCKET_IC_PATH"); + if (!string.IsNullOrEmpty(envPath)) + { + if (File.Exists(envPath)) + { + return envPath; + } + else + { + Console.WriteLine($"Warning: POCKET_IC_PATH environment variable is set, but file does not exist: {envPath}"); + } + } + + // List of possible locations to search for the binary + var searchPaths = new[] + { + AppContext.BaseDirectory, + Path.GetDirectoryName(typeof(PocketIcServer).Assembly.Location), + Environment.CurrentDirectory, + }; + + foreach (var basePath in searchPaths) + { + if (basePath == null) continue; + + string[] possiblePaths = new[] + { + Path.Combine(basePath, "runtimes", ridFolder, "native", fileName), + Path.Combine(basePath, fileName), + }; + + foreach (var path in possiblePaths) + { + if (File.Exists(path)) + { + return path; + } + } + } + + throw new FileNotFoundException($"PocketIC binary not found. Searched in {string.Join(", ", searchPaths)}, and POCKET_IC_PATH environment variable"); + } + private static void EnsureExecutablePermission(string filePath) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + var fileInfo = new FileInfo(filePath); + var unixFileMode = (UnixFileMode)fileInfo.UnixFileMode; + unixFileMode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(filePath, unixFileMode); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to set executable permission on '{filePath}': {ex.Message}", ex); + } + } + } + } +} diff --git a/src/PocketIC/Properties/AssemblyInfo.cs b/src/PocketIC/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..3beb1962 --- /dev/null +++ b/src/PocketIC/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PocketIC.Tests")] diff --git a/src/PocketIC/README.md b/src/PocketIC/README.md new file mode 100644 index 00000000..1185e08b --- /dev/null +++ b/src/PocketIC/README.md @@ -0,0 +1,204 @@ +# PocketIC + +Library containing the pocket-ic server runtime and clients for interfacing for canister testing on the internet computer + +- Nuget: [`EdjCase.ICP.PocketIC`](https://www.nuget.org/packages/EdjCase.ICP.PocketIC) + +## Supported PocketIC Server Runtimes + +The pocket-ic server binary is only compatible with some operating systems: + +- linux-x64 +- osx-x64 + +## Quick Start + +### Basic usage + +```cs +// Start server +await using PocketIcServer server = await PocketIcServer.Start(); +string pocketIcServerUrl = server.GetUrl(); + +// Create a new PocketIC instance with a default subnets +await using PocketIc pocketIc = await PocketIc.CreateAsync(pocketIcServerUrl); + +// Create a new canister +CreateCanisterResponse response = await pocketIc.CreateCanisterAsync(); +Principal canisterId = response.CanisterId; + +// Install WASM module +byte[] wasmModule = File.ReadAllBytes("path/to/my_canister.wasm"); +await pocketIc.InstallCodeAsync( + canisterId: canisterId, + wasmModule: wasmModule, + arg: CandidArg.FromCandid(), + mode: InstallCodeMode.Install +); + +// Start the canister +await pocketIc.StartCanisterAsync(canisterId); + +// Make calls to the canister +UnboundedUInt counter = await pocketIc.QueryCallAsync( + Principal.Anonymous(), + canisterId, + "my_method" +); + +``` + +### HTTP Gateway usage + +```cs + +await using HttpGateway httpGateway = await pocketIc.RunHttpGatewayAsync() + +// Create an HttpAgent to interact with canisters through the gateway +HttpAgent agent = httpGateway.BuildHttpAgent(); + +// Make calls using the agent +QueryResponse response = await agent.QueryAsync(canisterId, "my_method", CandidArg.Empty()); +CandidArg reply = response.ThrowOrGetReply(); + +``` + +## Subnet Configuration + +PocketIC instances can be created with various subnet configurations: + +```cs +await PocketIc.CreateAsync( + pocketIcServerUrl, + applicationSubnets: [SubnetConfig.New()], // Application subnets + nnsSubnet: SubnetConfig.New(), // Network Nervous System subnet + iiSubnet: SubnetConfig.New(), // Internet Identity subnet + bitcoinSubnet: SubnetConfig.New(), // Bitcoin subnet + snsSubnet: SubnetConfig.New(), // Service Nervous System subnet + systemSubnets: [SubnetConfig.New()] // System subnets +); +``` + +## Auto Time Progression + +PocketIC provides control over the IC time for testing: + +```cs + +// Enable automatic time progression +await using (await pocketIc.AutoProgressTimeAsync()) +{ + // Time will progress automatically +} +// Disposing will stop auto progress +``` + +## XUnit usage + +```cs +// Fixture to create and run server for all the tests, disposing only after all tests are complete +public class PocketIcServerFixture : IDisposable +{ + public PocketIcServer Server { get; private set; } + + public PocketIcServerFixture() + { + // Start the server for all tests + this.Server = PocketIcServer.Start(showRuntimeLogs: true).GetAwaiter().GetResult(); + } + + public void Dispose() + { + // Stop the server after all tests + if (this.Server != null) + { + this.Server.DisposeAsync().GetAwaiter().GetResult(); + } + } +} + +// Unit tests injecting the fixture +public class PocketIcTests : IClassFixture +{ + private readonly PocketIcServerFixture fixture; + private string url => this.fixture.Server.GetUrl(); + + public PocketIcTests(PocketIcServerFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task Test() + { + // Create new pocketic instance for test, then dispose it on completion + await using (PocketIc pocketIc = await PocketIc.CreateAsync(this.url)); + + // run test here + + } +} +``` + +## Call Types + +### Query Calls + +```cs +// No arguments +TResponse result = await pocketIc.QueryCallAsync( + sender, + canisterId, + "method" +); + +// With arguments +TResponse result = await pocketIc.QueryCallAsync( + sender, + canisterId, + "method", + arg1 +); + +// Raw candid +CandidArg response = await pocketIc.QueryCallRawAsync( + sender, + canisterId, + "method", + CandidArg.FromCandid() +); +``` + +### Update Calls + +```cs +// No arguments +TResponse result = await pocketIc.UpdateCallAsync( + sender, + canisterId, + "method" +); + +// With arguments +TResponse result = await pocketIc.UpdateCallAsync( + sender, + canisterId, + "method", + arg1 +); + +// No response +await pocketIc.UpdateCallNoResponseAsync( + sender, + canisterId, + "method" +); + +// Raw candid +CandidArg response = await pocketIc.UpdateCallRawAsync( + sender, + canisterId, + "method", + CandidArg.FromCandid() +); +``` diff --git a/src/PocketIC/runtimes/linux-x64/native/pocket-ic b/src/PocketIC/runtimes/linux-x64/native/pocket-ic new file mode 100755 index 00000000..84f59e98 Binary files /dev/null and b/src/PocketIC/runtimes/linux-x64/native/pocket-ic differ diff --git a/src/PocketIC/runtimes/osx-x64/native/pocket-ic b/src/PocketIC/runtimes/osx-x64/native/pocket-ic new file mode 100644 index 00000000..b69f5df2 Binary files /dev/null and b/src/PocketIC/runtimes/osx-x64/native/pocket-ic differ diff --git a/test/BLS.Tests/BLS.Tests.csproj b/test/BLS.Tests/BLS.Tests.csproj index 6d695ee5..d9a4f958 100644 --- a/test/BLS.Tests/BLS.Tests.csproj +++ b/test/BLS.Tests/BLS.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable BLS.Tests diff --git a/test/Candid.Tests/Candid.Tests.csproj b/test/Candid.Tests/Candid.Tests.csproj index e5d69294..5f0e6523 100644 --- a/test/Candid.Tests/Candid.Tests.csproj +++ b/test/Candid.Tests/Candid.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable false diff --git a/test/Performance.Tests/Performance.Tests.csproj b/test/Performance.Tests/Performance.Tests.csproj index 900dfc04..cd83f094 100644 --- a/test/Performance.Tests/Performance.Tests.csproj +++ b/test/Performance.Tests/Performance.Tests.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 enable enable diff --git a/test/PocketIC.Tests/CanisterWasmModules/counter.wasm b/test/PocketIC.Tests/CanisterWasmModules/counter.wasm new file mode 100644 index 00000000..84bb7846 Binary files /dev/null and b/test/PocketIC.Tests/CanisterWasmModules/counter.wasm differ diff --git a/test/PocketIC.Tests/PocketIC.Tests.csproj b/test/PocketIC.Tests/PocketIC.Tests.csproj new file mode 100644 index 00000000..fd5a5383 --- /dev/null +++ b/test/PocketIC.Tests/PocketIC.Tests.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + enable + + false + + EdjCase.ICP.PocketIC.Tests + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + PreserveNewest + + + + + + + + + diff --git a/test/PocketIC.Tests/PocketIc.Tests.cs b/test/PocketIC.Tests/PocketIc.Tests.cs new file mode 100644 index 00000000..2ab2e686 --- /dev/null +++ b/test/PocketIC.Tests/PocketIc.Tests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using EdjCase.ICP.Agent.Agents; +using EdjCase.ICP.Agent.Responses; +using EdjCase.ICP.Candid.Models; +using EdjCase.ICP.PocketIC.Client; +using EdjCase.ICP.PocketIC.Models; +using Xunit; + +namespace EdjCase.ICP.PocketIC.Tests; + + +public class PocketIcTests : IClassFixture +{ + private readonly PocketIcServerFixture fixture; + private string url => this.fixture.Server.GetUrl(); + + public PocketIcTests(PocketIcServerFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task Test() + { + SubnetConfig nnsSubnet = SubnetConfig.New(); + + IPocketIcHttpClient httpClient = new PocketIcHttpClient(new System.Net.Http.HttpClient(), this.url, TimeSpan.FromSeconds(5)); + int? instanceId = null; + // Create new pocketic instance for test, then dispose it + await using (PocketIc pocketIc = await PocketIc.CreateAsync(httpClient, nnsSubnet: nnsSubnet)) + { + instanceId = pocketIc.InstanceId; + + // Validate instance is available + List instances = await httpClient.GetInstancesAsync(); + Assert.Equal(InstanceStatus.Available, instances[instanceId.Value].Status); + + // Check topology + List subnetTopologies = await pocketIc.GetTopologyAsync(useCache: false); + Assert.NotNull(subnetTopologies); + + SubnetTopology appTopology = Assert.Single(subnetTopologies, s => s.Type == SubnetType.Application); + Assert.Equal(SubnetType.Application, appTopology.Type); + Assert.Equal(13, appTopology.NodeIds.Count); + + SubnetTopology nnsTopology = Assert.Single(subnetTopologies, s => s.Type == SubnetType.NNS); + Assert.Equal(SubnetType.NNS, nnsTopology.Type); + Assert.Equal(40, nnsTopology.NodeIds.Count); + + + UnboundedUInt initialCyclesAmount = 1_000_000_000_000; // 1 trillion cycles + + // Create canister + CreateCanisterResponse response = await pocketIc.CreateCanisterAsync( + settings: new CanisterSettings + { + ComputeAllocation = OptionalValue.WithValue(0), + Controllers = OptionalValue>.WithValue([Principal.Anonymous()]), + FreezingThreshold = OptionalValue.WithValue(0), + MemoryAllocation = OptionalValue.WithValue(0), + ReservedCyclesLimit = OptionalValue.WithValue(0), + }, + cyclesAmount: initialCyclesAmount, + specifiedId: null + ); + Assert.NotNull(response); + Assert.NotNull(response.CanisterId); + Principal canisterId = response.CanisterId; + + // Check cycles + ulong balance = await pocketIc.GetCyclesBalanceAsync(canisterId); + Assert.Equal(initialCyclesAmount, balance); + + ulong newBalance = await pocketIc.AddCyclesAsync(canisterId, 10); + Assert.Equal(balance + 10, newBalance); + + // Install code + byte[] wasmModule = File.ReadAllBytes("CanisterWasmModules/counter.wasm"); + CandidArg arg = CandidArg.FromCandid(); + + await pocketIc.InstallCodeAsync( + canisterId: canisterId, + wasmModule: wasmModule, + arg: arg, + mode: InstallCodeMode.Install + ); + + // Start canister + await pocketIc.StartCanisterAsync(canisterId); + + // Test 'get' counter value + UnboundedUInt counterValue = await pocketIc.QueryCallAsync( + Principal.Anonymous(), + canisterId, + "get" + ); + + Assert.Equal((UnboundedUInt)0, counterValue); + + // Test 'inc' counter value + await pocketIc.UpdateCallNoResponseAsync( + Principal.Anonymous(), + canisterId, + "inc" + ); + + // Test 'get' counter value after inc + counterValue = await pocketIc.QueryCallAsync( + Principal.Anonymous(), + canisterId, + "get" + ); + Assert.Equal((UnboundedUInt)1, counterValue); + + // Test tick doesn't throw + await pocketIc.TickAsync(); + + // Test time + ICTimestamp initialTime = await pocketIc.GetTimeAsync(); + + ICTimestamp newTime = new(initialTime.NanoSeconds + (UnboundedUInt)1_000); + await pocketIc.SetTimeAsync(newTime); + + ICTimestamp resetTime = await pocketIc.GetTimeAsync(); + + Assert.Equal(newTime.NanoSeconds, resetTime.NanoSeconds); + + // Test auto progress time + await using (await pocketIc.AutoProgressTimeAsync()) + { + await Task.Delay(100); + ICTimestamp autoProgressedTime = await pocketIc.GetTimeAsync(); + Assert.True(autoProgressedTime.NanoSeconds > resetTime.NanoSeconds); + } + + // Verify time is stopped + ICTimestamp stopProgressTime = await pocketIc.GetTimeAsync(); + await Task.Delay(100); + ICTimestamp stopProgressTime2 = await pocketIc.GetTimeAsync(); + Assert.Equal(stopProgressTime.NanoSeconds, stopProgressTime2.NanoSeconds); + + // Test subnet id + Principal subnetId = await pocketIc.GetSubnetIdForCanisterAsync(canisterId); + Assert.NotNull(subnetId); + + // Test public key + Principal publicKey = await pocketIc.GetPublicKeyForSubnetAsync(subnetId); + Assert.NotNull(publicKey); + + byte[] newStableMemory = new byte[8]; + newStableMemory[6] = 1; + await pocketIc.SetStableMemoryAsync(canisterId, newStableMemory); + + byte[] stableMemory = await pocketIc.GetStableMemoryAsync(canisterId); + Assert.Equal(newStableMemory, stableMemory[..8]); + + + // Setup http gateway and test api call to canister + await using (HttpGateway httpGateway = await pocketIc.RunHttpGatewayAsync()) + { + HttpAgent agent = httpGateway.BuildHttpAgent(); + QueryResponse getResponse = await agent.QueryAsync(canisterId, "get", CandidArg.Empty()); + CandidArg getResponseArg = getResponse.ThrowOrGetReply(); + UnboundedUInt getResponseValue = getResponseArg.ToObjects(); + Assert.Equal((UnboundedUInt)1, getResponseValue); + + // CancellationTokenSource cts = new(TimeSpan.FromSeconds(5)); + // CandidArg incResponseArg = await agent.CallAndWaitAsync(canisterId, "inc", CandidArg.Empty(), cancellationToken: cts.Token); + // Assert.Equal(CandidArg.Empty(), incResponseArg); + + // getResponse = await agent.QueryAsync(canisterId, "get", CandidArg.Empty()); + // getResponseArg = getResponse.ThrowOrGetReply(); + // getResponseValue = getResponseArg.ToObjects(); + // Assert.Equal((UnboundedUInt)2, getResponseValue); + + } + + + // Stop canister + await pocketIc.StopCanisterAsync(canisterId); + } + if (instanceId != null) + { + List instances = await httpClient.GetInstancesAsync(); + Assert.Equal(InstanceStatus.Deleted, instances[instanceId.Value].Status); + } + } +} diff --git a/test/PocketIC.Tests/PocketIcHttpClient.Tests.cs b/test/PocketIC.Tests/PocketIcHttpClient.Tests.cs new file mode 100644 index 00000000..b1f71c6c --- /dev/null +++ b/test/PocketIC.Tests/PocketIcHttpClient.Tests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using EdjCase.ICP.Candid.Models; +using EdjCase.ICP.PocketIC; +using EdjCase.ICP.PocketIC.Client; +using EdjCase.ICP.PocketIC.Models; +using Xunit; + +namespace EdjCase.ICP.PocketIC.Tests; + + +public class PocketIcHttpClientTests : IClassFixture +{ + private readonly PocketIcServerFixture fixture; + private string url => this.fixture.Server.GetUrl(); + + public PocketIcHttpClientTests(PocketIcServerFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task Test() + { + PocketIcHttpClient client = new(new HttpClient(), this.url, TimeSpan.FromSeconds(5)); + List instances = await client.GetInstancesAsync(); + Assert.NotNull(instances); + Assert.Empty(instances); + + // Create Instance + (int instanceId, _) = await client.CreateInstanceAsync(); + + instances = await client.GetInstancesAsync(); + Assert.NotNull(instances); + Assert.Single(instances); + Assert.Equal(instanceId, instances[0].Id); + Assert.Equal(InstanceStatus.Available, instances[0].Status); + + // Check topology + List subnetTopologies = await client.GetTopologyAsync(instanceId); + Assert.NotNull(subnetTopologies); + SubnetTopology subnetTopology = Assert.Single(subnetTopologies); + Assert.Equal(SubnetType.Application, subnetTopology.Type); + Assert.Equal(13, subnetTopology.NodeIds.Count); + + + + // Upload and download blob + byte[] blob = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + string blobId = await client.UploadBlobAsync(blob); + Assert.NotNull(blobId); + Assert.NotEmpty(blobId); + + byte[] downloadedBlob = await client.DownloadBlobAsync(blobId); + Assert.NotNull(downloadedBlob); + Assert.Equal(blob, downloadedBlob); + + // Get time + ICTimestamp timestamp = await client.GetTimeAsync(instanceId); + Assert.NotNull(timestamp); + + // Set time + await client.SetTimeAsync(instanceId, timestamp); + + // Tick + await client.TickAsync(0); + + Principal subnetPublicKey = await client.GetPublicKeyForSubnetAsync(instanceId, subnetTopology.Id); + Assert.NotNull(subnetPublicKey); + + // TODO + // byte[] message = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + // byte[] signature = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + // Principal publicKey = Principal.Anonymous(); + // bool validSignature = await client.VerifySignatureAsync(message, publicKey, subnetPublicKey, signature); + // Assert.True(validSignature); + + // Delete the instance + await client.DeleteInstanceAsync(instanceId); + + instances = await client.GetInstancesAsync(); + Assert.NotNull(instances); + Assert.Single(instances); + Assert.Equal(instanceId, instances[0].Id); + Assert.Equal(InstanceStatus.Deleted, instances[0].Status); + } +} + diff --git a/test/PocketIC.Tests/PocketIcServerFixture.cs b/test/PocketIC.Tests/PocketIcServerFixture.cs new file mode 100644 index 00000000..c3a2cff7 --- /dev/null +++ b/test/PocketIC.Tests/PocketIcServerFixture.cs @@ -0,0 +1,25 @@ + + +using System; + +namespace EdjCase.ICP.PocketIC.Tests; + +public class PocketIcServerFixture : IDisposable +{ + public PocketIcServer Server { get; private set; } + + public PocketIcServerFixture() + { + // Start the server for all tests + this.Server = PocketIcServer.Start(showRuntimeLogs: true).GetAwaiter().GetResult(); + } + + public void Dispose() + { + // Stop the server after all tests + if (this.Server != null) + { + this.Server.DisposeAsync().GetAwaiter().GetResult(); + } + } +} \ No newline at end of file diff --git a/test/WebSockets.Tests/WebSockets.Tests.csproj b/test/WebSockets.Tests/WebSockets.Tests.csproj index 34f0e365..bb6769e7 100644 --- a/test/WebSockets.Tests/WebSockets.Tests.csproj +++ b/test/WebSockets.Tests/WebSockets.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable