Skip to content

Commit

Permalink
feat: cover fees for app call transactions that send inner transactio…
Browse files Browse the repository at this point in the history
…ns (#357)

* feat: cover app call inner transaction fees
  • Loading branch information
neilcampbell authored Jan 17, 2025
1 parent 7199dde commit 2c2dd78
Show file tree
Hide file tree
Showing 28 changed files with 1,770 additions and 429 deletions.
6 changes: 4 additions & 2 deletions docs/capabilities/algorand-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The `AlgorandClient` has a number of manager class instances that help you quick

### Creating transactions

You can compose a transaction via `algorand.createTransaction...`, which gives you an instance of the [`AlgorandClientTransactionCreator`](../code/classes/types_algorand_client_transaction_creator.AlgorandClientTransactionCreator.md) class. Intellisense will guide you on the different options.
You can compose a transaction via `algorand.createTransaction.`, which gives you an instance of the [`AlgorandClientTransactionCreator`](../code/classes/types_algorand_client_transaction_creator.AlgorandClientTransactionCreator.md) class. Intellisense will guide you on the different options.

The signature for the calls to send a single transaction usually look like:

Expand Down Expand Up @@ -147,8 +147,10 @@ There are two common base interfaces that get reused:
- [`SendParams`](../code/interfaces/types_transaction.SendParams.md)
- `maxRoundsToWaitForConfirmation?: number` - The number of rounds to wait for confirmation. By default until the latest lastValid has past.
- `suppressLog?: boolean` - Whether to suppress log messages from transaction send, default: do not suppress.
- `populateAppCallResources?: boolean` - Whether to use simulate to automatically populate app call resources in the txn objects. Defaults to `Config.populateAppCallResources`.
- `coverAppCallInnerTransactionFees?: boolean` - Whether to use simulate to automatically calculate required app call inner transaction fees and cover them in the parent app call transaction fee

Then on top of that the base type gets extended for the specific type of transaction you are issuing. These are all defined as part of [`TransactionComposer`](./transaction-composer.md).
Then on top of that the base type gets extended for the specific type of transaction you are issuing. These are all defined as part of [`TransactionComposer`](./transaction-composer.md) and we recommend reading these docs, especially when leveraging either `populateAppCallResources` or `coverAppCallInnerTransactionFees`.

### Transaction configuration

Expand Down
4 changes: 2 additions & 2 deletions docs/capabilities/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ The `fixture.context` property is of type [`AlgorandTestAutomationContext`](../c
- `transactionLogger: TransactionLogger` - Transaction logger that will log transaction IDs for all transactions issued by `algod`
- `testAccount: Account` - Funded test account that is ephemerally created for each test
- `generateAccount: (params: GetTestAccountParams) => Promise<Account>` - Generate and fund an additional ephemerally created account
- `waitForIndexer()` - Waits for indexer to catch up with that latest transaction that has been captured by the `transactionLogger` in the Algorand fixture
- `waitForIndexer()` - Waits for indexer to catch up with the latest transaction that has been captured by the `transactionLogger` in the Algorand fixture
- `waitForIndexerTransaction: (transactionId: string) => Promise<TransactionLookupResult>` - Wait for the indexer to catch up with the given transaction ID

## Log capture fixture
Expand Down Expand Up @@ -217,7 +217,7 @@ This means it's easy to create tests that are flaky and have intermittent test f
The testing capability provides mechanisms for waiting for indexer to catch up, namely:

- `algotesting.runWhenIndexerCaughtUp(run: () => Promise<T>)` - Executes the given action every 200ms up to 20 times until there is no longer an error with a `status` property with `404` and then returns the result of the action; this will work for any call that calls indexer APIs expecting to return a single record
- `algorandFixture.waitForIndexer()` - Waits for indexer to catch up with that latest transaction that has been captured by the `transactionLogger` in the Algorand fixture
- `algorandFixture.waitForIndexer()` - Waits for indexer to catch up with the latest transaction that has been captured by the `transactionLogger` in the Algorand fixture
- `algorandFixture.waitForIndexerTransaction(transactionId)` - Waits for indexer to catch up with the single transaction of the given ID

## Logging transactions
Expand Down
137 changes: 136 additions & 1 deletion docs/capabilities/transaction-composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,152 @@ The [methods to construct a transaction](../code/classes/types_composer.default.
For example:

```typescript
const myMethod = algosdk.ABIMethod.fromSignature('my_method()void')
const result = algorand
.newGroup()
.addPayment({ sender: 'SENDER', receiver: 'RECEIVER', amount: (100).microAlgo() })
.addAppCallMethodCall({
sender: 'SENDER',
appId: 123n,
method: abiMethod,
method: myMethod,
args: [1, 2, 3],
})
```

## Sending a transaction

Once you have constructed all the required transactions, they can be sent by calling `send()` on the `TransactionComposer`.
Additionally `send()` takes a number of parameters which allow you to opt-in to some additional behaviours as part of sending the transaction or transaction group, mostly significantly `populateAppCallResources` and `coverAppCallInnerTransactionFees`.

### Populating App Call Resource

`populateAppCallResources` automatically updates the relevant app call transactions in the group to include the account, app, asset and box resources required for the transactions to execute successfully. It leverages the simulate endpoint to discover the accessed resources, which have not been explicitly specified. This setting only applies when you have constucted at least one app call transaction. You can read more about [resources and the reference arrays](https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/?from_query=resources#reference-arrays) in the docs.

For example:

```typescript
const myMethod = algosdk.ABIMethod.fromSignature('my_method()void')
const result = algorand
.newGroup()
.addAppCallMethodCall({
sender: 'SENDER',
appId: 123n,
method: myMethod,
args: [1, 2, 3],
})
.send({
populateAppCallResources: true,
})
```

If `my_method` in the above example accesses any resources, they will be automatically discovered and added before sending the transaction to the network.

### Covering App Call Inner Transaction Fees

`coverAppCallInnerTransactionFees` automatically calculate the required fee for a parent app call transaction that sends inner transactions. It leverages the simulate endpoint to discover the inner transactions sent and calculates a fee delta to resolve the optimal fee. This feature also takes care of accounting for any surplus transaction fee at the various levels, so as to effectively minimise the fees needed to successfully handle complex scenarios. This setting only applies when you have constucted at least one app call transaction.

For example:

```typescript
const myMethod = algosdk.ABIMethod.fromSignature('my_method()void')
const result = algorand
.newGroup()
.addAppCallMethodCall({
sender: 'SENDER',
appId: 123n,
method: myMethod,
args: [1, 2, 3],
maxFee: microAlgo(5000), // NOTE: a maxFee value is required when enabling coverAppCallInnerTransactionFees
})
.send({
coverAppCallInnerTransactionFees: true,
})
```

Assuming the app account is not covering any of the inner transaction fees, if `my_method` in the above example sends 2 inner transactions, then the fee calculated for the parent transaction will be 3000 µALGO when the transaction is sent to the network.

The above example also has a `maxFee` of 5000 µALGO specified. An exception will be thrown if the transaction fee execeeds that value, which allows you to set fee limits. The `maxFee` field is required when enabling `coverAppCallInnerTransactionFees`.

Because `maxFee` is required and an `algosdk.Transaction` does not hold any max fee information, you cannot use the generic `addTransaction()` method on the composer with `coverAppCallInnerTransactionFees` enabled. Instead use the below, which provides a better overall experience:

```typescript
const myMethod = algosdk.ABIMethod.fromSignature('my_method()void')

// Does not work
const result = algorand
.newGroup()
.addTransaction((await localnet.algorand.createTransaction.appCallMethodCall({
sender: 'SENDER',
appId: 123n,
method: myMethod,
args: [1, 2, 3],
maxFee: microAlgo(5000), // This is only used to create the algosdk.Transaction object and isn't made available to the composer.
})).transactions[0]),
.send({
coverAppCallInnerTransactionFees: true,
})

// Works as expected
const result = algorand
.newGroup()
.addAppCallMethodCall({
sender: 'SENDER',
appId: 123n,
method: myMethod,
args: [1, 2, 3],
maxFee: microAlgo(5000),
})
.send({
coverAppCallInnerTransactionFees: true,
})
```

A more complex valid scenario which leverages an app client to send an ABI method call with ABI method call transactions argument is below:

```typescript
const appFactory = algorand.client.getAppFactory({
appSpec: 'APP_SPEC',
defaultSender: sender.addr,
})

const { appClient: appClient1 } = await appFactory.send.bare.create()
const { appClient: appClient2 } = await appFactory.send.bare.create()

const paymentArg = algorand.createTransaction.payment({
sender: sender.addr,
receiver: receiver.addr,
amount: microAlgo(1),
})

// Note the use of .params. here, this ensure that maxFee is still available to the composer
const appCallArg = await appClient2.params.call({
method: 'my_other_method',
args: [],
maxFee: microAlgo(2000),
})

const result = await appClient1.algorand
.newGroup()
.addAppCallMethodCall(
await appClient1.params.call({
method: 'my_method',
args: [paymentArg, appCallArg],
maxFee: microAlgo(5000),
}),
)
.send({
coverAppCallInnerTransactionFees: true,
})
```

This feature should efficiently calculate the minimum fee needed to execute an app call transaction with inners, however we always recommend testing your specific scenario behaves as expected before releasing.

### Covering App Call Op Budget

The high level Algorand contract authoring languages all have support for ensuring appropriate app op budget is available via `ensure_budget` in Algorand Python, `ensureBudget` in Algorand TypeScript and `increaseOpcodeBudget` in TEALScript. This is great, as it allows contract authors to ensure appropriate budget is available by automatically sending op-up inner transactions to increase the budget available. These op-up inner transactions require the fees to be covered by an account, which is generally the responsibility of the application consumer.

Application consumers may not be immediately aware of the number of op-up inner transactions sent, so it can be difficult for them to determine the exact fees required to successfully execute an application call. Fortunately the `coverAppCallInnerTransactionFees` setting above can be leveraged to automatically cover the fees for any op-up inner transaction that an application sends. Additionally if a contract author decides to cover the fee for an op-up inner transaction, then the application consumer will not be charged a fee for that transaction.

## Simulating a transaction

Transactions can be simulated using the simulate endpoint in algod, which enables evaluating the transaction on the network without it actually being commited to a block.
Expand Down
2 changes: 1 addition & 1 deletion docs/capabilities/transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ There are various variations of the `ConfirmedTransactionResult` that are expose

## Further reading

To understand how to create, simulate and send transactions consult the [`AlgorandClient`](./algorand-client.md) and [`AlgorandClient`](./algokit-composer.md) documentation.
To understand how to create, simulate and send transactions consult the [`AlgorandClient`](./algorand-client.md) and [`TransactionComposer`](./transaction-composer.md) documentation.
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ await algorand.createTransaction.appCreate({
| `params.assetReferences?` | `bigint`[] | The ID of any assets to load to the [foreign assets array](https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/#reference-arrays). |
| `params.boxReferences?` | ([`BoxIdentifier`](../modules/types_app_manager.md#boxidentifier) \| [`BoxReference`](../interfaces/types_app_manager.BoxReference.md))[] | Any boxes to load to the [boxes array](https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/#reference-arrays). Either the name identifier (which will be set against app ID of `0` i.e. the current app), or a box identifier with the name identifier and app ID. |
| `params.clearStateProgram` | `string` \| `Uint8Array` | The program to execute for ClearState OnComplete as raw teal that will be compiled (string) or compiled teal (encoded as a byte array (Uint8Array)). |
| `params.extraFee?` | [`AlgoAmount`](types_amount.AlgoAmount.md) | The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. |
| `params.extraFee?` | [`AlgoAmount`](types_amount.AlgoAmount.md) | The fee to pay IN ADDITION to the suggested fee. Useful for manually covering inner transaction fees. |
| `params.extraProgramPages?` | `number` | Number of extra pages required for the programs. Defaults to the number needed for the programs in this call if not specified. This is immutable once the app is created. |
| `params.firstValidRound?` | `bigint` | Set the first round this transaction is valid. If left undefined, the value from algod will be used. We recommend you only set this when you intentionally want this to be some time in the future. |
| `params.lastValidRound?` | `bigint` | The last round this transaction is valid. It is recommended to use `validityWindow` instead. |
Expand Down Expand Up @@ -609,7 +609,7 @@ await algorand.createTransaction.appUpdate({
| `params.assetReferences?` | `bigint`[] | The ID of any assets to load to the [foreign assets array](https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/#reference-arrays). |
| `params.boxReferences?` | ([`BoxIdentifier`](../modules/types_app_manager.md#boxidentifier) \| [`BoxReference`](../interfaces/types_app_manager.BoxReference.md))[] | Any boxes to load to the [boxes array](https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/#reference-arrays). Either the name identifier (which will be set against app ID of `0` i.e. the current app), or a box identifier with the name identifier and app ID. |
| `params.clearStateProgram` | `string` \| `Uint8Array` | The program to execute for ClearState OnComplete as raw teal (string) or compiled teal (base 64 encoded as a byte array (Uint8Array)) |
| `params.extraFee?` | [`AlgoAmount`](types_amount.AlgoAmount.md) | The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. |
| `params.extraFee?` | [`AlgoAmount`](types_amount.AlgoAmount.md) | The fee to pay IN ADDITION to the suggested fee. Useful for manually covering inner transaction fees. |
| `params.firstValidRound?` | `bigint` | Set the first round this transaction is valid. If left undefined, the value from algod will be used. We recommend you only set this when you intentionally want this to be some time in the future. |
| `params.lastValidRound?` | `bigint` | The last round this transaction is valid. It is recommended to use `validityWindow` instead. |
| `params.lease?` | `string` \| `Uint8Array` | Prevent multiple transactions with the same lease being included within the validity window. A [lease](https://developer.algorand.org/articles/leased-transactions-securing-advanced-smart-contract-design/) enforces a mutually exclusive transaction (useful to prevent double-posting and other scenarios). |
Expand Down
Loading

0 comments on commit 2c2dd78

Please sign in to comment.