Skip to main content

FlowControl scripting example

This example shows how to use FlowControl to perform common tasks purely from C#, without using the FlowControl editor interface. This will ignore any of the GUI related code and focus on using the FlowControl and Flow SDK.

Prerequisites

Ensure you have flow-cli installed. This will allow us to use an emulated flow environment. You can install it by following the instructions at Flow CLI

Sample walk through

You can follow along in FlowControlExample.cs

The first thing to notice is that we declare Start() to be an IEnumerator. This makes Start a coroutine. You will always want to run FlowSDK functions inside a coroutine because they can take a while to complete and you don't want to lock up your game while they are processed.


_10
private IEnumerator Start()
_10
{
_10
...
_10
}

Checking emulator state

The next thing we do is ensure the emulator is running. We give it a few seconds to start:


_14
//Wait up to 2.5 seconds for the emulator to start.
_14
int waited = 0;
_14
_14
while (!FlowControl.IsEmulatorRunning && waited < 5)
_14
{
_14
waited++;
_14
yield return new WaitForSeconds(.5f);
_14
}
_14
_14
if (!FlowControl.IsEmulatorRunning)
_14
{
_14
//Stop execution if the emulator is not running by now.
_14
yield break;
_14
}

Creating a FlowControl Account

Next we'll create a FlowControl account to use ONLY for running scripts. The Flow network doesn't require an account to run scripts, but FlowControl uses Accounts to determine which network to connect to.


_10
FlowControl.Account scriptOnlyAccount = new FlowControl.Account
_10
{
_10
GatewayName = "Emulator"
_10
};

Because this account doesn't have any keys associated with it, it can't be used to run transactions. It does define which Gateway to use, in this case the "Emulator" gateway, so it can be used to run scripts.

Running scripts

Next, we'll use this account to run a script on the emulator. Scripts on Flow are written in Cadence. More information is available at Developer Portal

First we'll define the script that we want to run:


_10
const string code = @"pub fun main(message: String): Int{
_10
log(message)
_10
return 42
_10
}";

This script requires a Cadence String as input, returns a Cadence Int, and will log the input string to the emulator log.

Now we execute this script:


_10
Task<FlowScriptResponse> task = scriptOnlyAccount.ExecuteScript(code, new CadenceString("Test"));

FlowControl uses an Account oriented approach. Everything is done using an Account object. In this case we'll use the scriptOnlyAccount account that we created earlier to call ExecuteScript.

A script is code that can not permanently mutate the state of the blockchain. It is read-only. It CAN call functions that would change the state of the blockchain, but any changes that are made will be discarded once the script finishes running.

We pass in the Cadence code we want to run and any arguments that are required by the script. We need to use Cadence specific data types, so we construct a new CadenceString using the string "Test".

This returns a Task<FlowScriptResponse>. This is an asynchronous Task that will result in a FlowScriptResponse when it is complete.

Next, we need to wait for the Task to complete. Inside a Unity coroutine we can use the WaitUntil function as follows:


_10
yield return new WaitUntil(() => task.IsCompleted);

WaitUntil takes a function that returns a bool (Func<bool>), so we construct an anonymous one that returns the IsCompleted field of the task. This cause Unity to pause execution of the current coroutine until the task is completed.

We then check to see if an error occured, and if so, log it to the console.


_10
if (task.Result.Error != null)
_10
{
_10
Debug.LogError($"Error: {task.Result.Error.Message}");
_10
yield break;
_10
}

If there is no error, the script should have returned a Cadence Int value. We can access it as follows:


_10
Debug.Log($"Script result: {task.Result.Value.As<CadenceNumber>().Value}");

This might be a bit confusing. The Task will have a Result. The result could contain an error, but we checked for that earlier. If it doesn't contain an error, then it will contain a Value.

That Value will be of type CadenceBase, which is the base type for all Cadence data types. We know that the script returns a number, so we can cast it as a CadenceNumber using As<CadenceNumber>(). All Cadence types contain Value and Type members that are strings. In this case, we're interested in the Value. If we wanted to use it as a number, we'd need to parse it, but in this case we just want to output it, so leaving it as a string is fine.

Creating an SdkAccount

Next, let's create an account that can be used to execute transactions that mutate the state of the blockchain. This will also demonstrate how you can use both FlowControl and the base SDK together.


_10
SdkAccount emulatorSdkAccount = FlowControl.GetSdkAccountByName("emulator_service_account");
_10
if (emulatorSdkAccount == null)
_10
{
_10
Debug.LogError("Error getting SdkAccount for emulator_service_account");
_10
yield break;
_10
}

When the emulator is started, FlowControl automatically creates an emulator_service_account FlowControl.Account for you to use to access the built in emulator service account. We'll use that account to create a new account.

Because the CreateAccount function is an SDK function, and not a FlowControl function, we'll need to create a temporary SdkAccount from the FlowControl Account. The GetSdkAccountByName function will construct an SdkAccount object from a FlowControl.Account object.

If the name you pass to FlowControl.GetSdkAccountByName does not exist, it will return null, so we check for that and stop execution if it fails.

Creating an account on the blockchain

Now we'll use this new SdkAccount object to create a new Flow account on the emulated blockchain.


_32
FlowSDK.RegisterWalletProvider(ScriptableObject.CreateInstance<DevWalletProvider>());
_32
_32
string authAddress = "";
_32
FlowSDK.GetWalletProvider().Authenticate("", (string address) =>
_32
{
_32
authAddress = address;
_32
}, null);
_32
_32
yield return new WaitUntil(() => { return authAddress != ""; });
_32
_32
//Convert FlowAccount to SdkAccount
_32
SdkAccount emulatorSdkAccount = FlowControl.GetSdkAccountByAddress(authAddress);
_32
if (emulatorSdkAccount == null)
_32
{
_32
Debug.LogError("Error getting SdkAccount for emulator_service_account");
_32
yield break;
_32
}
_32
_32
//Create a new account with the name "User"
_32
Task<SdkAccount> newAccountTask = CommonTransactions.CreateAccount("User");
_32
yield return new WaitUntil(() => newAccountTask.IsCompleted);
_32
_32
if (newAccountTask.Result.Error != null)
_32
{
_32
Debug.LogError($"Error creating new account: {newAccountTask.Result.Error.Message}");
_32
yield break;
_32
}
_32
_32
outputText.text += "DONE\n\n";
_32
_32
//Here we have an SdkAccount
_32
SdkAccount userSdkAccount = newAccountTask.Result;

First we create and register a new DevWalletProvider. Any time a transaction is run, it calls the provided wallet provider. The DevWalletProvider is an implementation of IWallet that shows a simulated wallet interface. It will allow you to view and authorize the submitted transaction.

After creating and registering the wallet provider, we call Authenticate to display a popup that will allow you to select any of the accounts in the FlowControl Accounts tab. You should choose emulator_service_account when prompted when running the demo.

We then wait until the user has selected an account.

CommonTransactions contains some utility functions to make performing frequent operations a little easier. One of these is CreateAccount. It expects a Name, which is not placed on the blockchain, and the SdkAccount that should pay for the creation of the new account. That returns a Task that is handled similarly to before.

If there is no error, the Result field of the task will contain the newly create account info.

Now, in order to use this new account with FlowControl, we'll need to create a FlowControl.Account from the SdkAccount we have.


_10
FlowControl.Account userAccount = new FlowControl.Account
_10
{
_10
Name = userSdkAccount.Name,
_10
GatewayName = "Emulator",
_10
AccountConfig = new Dictionary<string, string>
_10
{
_10
["Address"] = userSdkAccount.Address,
_10
["Private Key"] = userSdkAccount.PrivateKey
_10
}
_10
};

Then we store this account in the FlowControlData object so that we can look it up by name later.


_10
FlowControl.Data.Accounts.Add(userAccount);

Deploying a contract

The next section shows how to deploy a contract to the Flow network. Because this is another utility function from CommonTransactions, it needs an SdkAccount. We'll use userSdkAccount we created earlier.

First we need to define the contract we wish to deploy.


_15
const string contractCode = @"
_15
pub contract HelloWorld {
_15
pub let greeting: String
_15
_15
pub event TestEvent(field: String)
_15
_15
init() {
_15
self.greeting = ""Hello, World!""
_15
}
_15
_15
pub fun hello(data: String): String {
_15
emit TestEvent(field:data)
_15
return self.greeting
_15
}
_15
}";

We won't discuss how to write Flow contracts in depth here, but simply put this contract defines a single function that will emit an event and return the string "Hello World!" when run.

Then we use the same pattern we've used before to deploy this contract using the CommonTransaction.DeployContract function. Note that we should register a new wallet provider since we are changing the account we want to run the transaction as.


_11
FlowSDK.GetWalletProvider().Authenticate(userAccount.Name, null, null);
_11
Task<FlowTransactionResponse> deployContractTask =
_11
CommonTransactions.DeployContract("HelloWorld", contractCode);
_11
_11
yield return new WaitUntil(() => deployContractTask.IsCompleted);
_11
_11
if (deployContractTask.Result.Error != null)
_11
{
_11
Debug.LogError($"Error deploying contract: {deployContractTask.Result.Error.Message}");
_11
yield break;
_11
}

We'll reauthenticate with the wallet provider to tell it to use the new newly created account. Because we pass in a name this time, it won't display the select account pop-up.

The first argument to DeployContract is the contract name. This must match the name in the contract data itself. The second argument is the Cadence code that defines the contract, and the third argument is the SdkAccount that the contract should be deployed to.

Replacement text

Next we'll see how to add a ReplacementText entry to FlowControl. This is typically done via the FlowControl editor interface, but can be done programatically as shown.


_11
FlowControl.TextReplacement newTextReplacement = new FlowControl.TextReplacement
_11
{
_11
description = "User Address",
_11
originalText = "%USERADDRESS%",
_11
replacementText = userSdkAccount.Address,
_11
active = true,
_11
ApplyToAccounts = new List<string> { "User" },
_11
ApplyToGateways = new List<string> { "Emulator" }
_11
};
_11
_11
FlowControl.Data.TextReplacements.Add(newTextReplacement);

Note that we are setting ApplyToAccounts and ApplyToGateways so that this TextReplacement will be performed any time the FlowControl.Account account with the name "User" executes a function against the emulator.

This new TextReplacement will be used when we execute a transaction using the contract we just deployed.

Transactions

First we'll write the transaction we want to execute.


_10
string transaction = @"
_10
import HelloWorld from %USERADDRESS%
_10
transaction {
_10
prepare(acct: AuthAccount) {
_10
log(""Transaction Test"")
_10
HelloWorld.hello(data:""Test Event"")
_10
}
_10
}";

Based on the TextReplacement we created earlier, %USERADDRESS% will be replaced with the Flow address of the user account we created. This will then call the hello function on the HelloWorld contract we deployed to the user account.

Next we follow a similar pattern to before:


_10
Task<FlowTransactionResult> transactionTask = userAccount.SubmitAndWaitUntilSealed(transaction);
_10
yield return new WaitUntil(() => transactionTask.IsCompleted);
_10
_10
if (transactionTask.Result.Error != null || !string.IsNullOrEmpty(transactionTask.Result.ErrorMessage))
_10
{
_10
Debug.LogError($"Error executing transaction: {transactionTask.Result.Error?.Message??transactionTask.Result.ErrorMessage}");
_10
yield break;
_10
}

Here, we're using the SubmitAndWaitUntilSealed FlowControl function. This combines two SDK functions together. It first submits the transaction to the network. Then it polls the network until the network indicates that the transaction has been sealed and then returns the results.

Because this is combining two operations together, there are two potential failure points. The first is a network error or syntax error that causes the submission to be rejected. This will be indicated in the Result.Error field. The second is something that goes wrong during the processing of the transaction after submission was successful. This will be indicated in the Result.ErrorMessage field. When using SubmitAndWaitUntilSealed or SubmitAndWaitUntilExecuted, you will want to check both of the error fields to ensure it has completed successfully.

Finally, we check the events emitted by the transaction. Because submitting transactions returns before the transaction is actually processed, you can't return data directly from a transaction like you can with a script. Instead, you emit events that can be retrieved. We'll check the events of the completed transaction as follows:

Transaction Events


_10
FlowEvent txEvent = transactionTask.Result.Events.Find(x => x.Type.Contains("TestEvent"));
_10
_10
//Show that the transaction finished and display the value of the event that was emitted during execution.
_10
//The Payload of the returned FlowEvent will be a CadenceComposite. We want the value associated with the
_10
//"field" field as a string
_10
Debug.Log($"Executed transaction. Event type: {txEvent.Type}. Event payload: {txEvent.Payload.As<CadenceComposite>().CompositeFieldAs<CadenceString>("field").Value}");

We end up a with a list of Events that were emitted by a transaction in the Result.Events object. We use LINQ to find the event we're interested in. It will contain "TestEvent" in it.

Then we have to get the payload from the event to display. The payload will always be a CadenceComposite, which can contain many fields. We'll get the field named "field" from it, and cast that to a CadenceString using CompositeFieldAs<CadenceString>("field"), and finally get the Value of that field to display.