Skip to content
This new developer portal is under construction. For complete documentation, please refer to the old developer portal.

ASA Inbox Router

Abstract

The goal of this standard is to establish a standard in the Algorand ecosystem by which ASAs can be sent to an intended receiver even if their account is not opted in to the ASA.

A wallet custodied by an application will be used to custody assets on behalf of a given user, with only that user being able to withdraw assets. A master application will be used to map inbox addresses to user address. This master application can route ASAs to users performing whatever actions are necessary.

If integrated into ecosystem technologies including wallets, explorers, and dApps, this standard can provide enhanced capabilities around ASAs which are otherwise strictly bound at the protocol level to require opting in to be received.

Motivation

Algorand requires accounts to opt in to receive any ASA, a fact which simultaneously:

  1. Grants account holders fine-grained control over their holdings by allowing them to select which assets to allow and preventing receipt of unwanted tokens.
  2. Frustrates users and developers when accounting for this requirement especially since other blockchains do not have this requirement.

This ARC lays out a new way to navigate the ASA opt in requirement.

Contemplated Use Cases

The following use cases help explain how this capability can enhance the possibilities within the Algorand ecosystem.

Airdrops

An ASA creator who wants to send their asset to a set of accounts faces the challenge of needing their intended receivers to opt in to the ASA ahead of time, which requires non-trivial communication efforts and precludes the possibility of completing the airdrop as a surprise. This claimable ASA standard creates the ability to send an airdrop out to individual addresses so that the receivers can opt in and claim the asset at their convenience—or not, if they so choose.

Reducing New User On-boarding Friction

An application operator who wants to on-board users to their game or business may want to reduce the friction of getting people started by decoupling their application on-boarding process from the process of funding a non-custodial Algorand wallet, if users are wholly new to the Algorand ecosystem. As long as the receiver’s address is known, an ASA can be sent to them ahead of them having ALGOs in their wallet to cover the minimum balance requirement and opt in to the asset.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC-2119.

Router Contract Interface

1
{
2
"name": "ARC59",
3
"desc": "",
4
"methods": [
5
{
6
"name": "createApplication",
7
"desc": "Deploy ARC59 contract",
8
"args": [],
9
"returns": {
10
"type": "void"
11
}
12
},
13
{
14
"name": "arc59_optRouterIn",
15
"desc": "Opt the ARC59 router into the ASA. This is required before this app can be used to send the ASA to anyone.",
16
"args": [
17
{
18
"name": "asa",
19
"type": "uint64",
20
"desc": "The ASA to opt into"
21
}
22
],
23
"returns": {
24
"type": "void"
25
}
26
},
27
{
28
"name": "arc59_getOrCreateInbox",
29
"desc": "Gets the existing inbox for the receiver or creates a new one if it does not exist",
30
"args": [
31
{
32
"name": "receiver",
33
"type": "address",
34
"desc": "The address to get or create the inbox for"
35
}
36
],
37
"returns": {
38
"type": "address",
39
"desc": "The inbox address"
40
}
41
},
42
{
43
"name": "arc59_getSendAssetInfo",
44
"args": [
45
{
46
"name": "receiver",
47
"type": "address",
48
"desc": "The address to send the asset to"
49
},
50
{
51
"name": "asset",
52
"type": "uint64",
53
"desc": "The asset to send"
54
}
55
],
56
"returns": {
57
"type": "(uint64,uint64,bool,bool,uint64)",
58
"desc": "Returns the following information for sending an asset:The number of itxns required, the MBR required, whether the router is opted in, and whether the receiver is opted in"
59
}
60
},
61
{
62
"name": "arc59_sendAsset",
63
"desc": "Send an asset to the receiver",
64
"args": [
65
{
66
"name": "axfer",
67
"type": "axfer",
68
"desc": "The asset transfer to this app"
69
},
70
{
71
"name": "receiver",
72
"type": "address",
73
"desc": "The address to send the asset to"
74
},
75
{
76
"name": "additionalReceiverFunds",
77
"type": "uint64",
78
"desc": "The amount of ALGO to send to the receiver/inbox in addition to the MBR"
79
}
80
],
81
"returns": {
82
"type": "address",
83
"desc": "The address that the asset was sent to (either the receiver or their inbox)"
84
}
85
},
86
{
87
"name": "arc59_claim",
88
"desc": "Claim an ASA from the inbox",
89
"args": [
90
{
91
"name": "asa",
92
"type": "uint64",
93
"desc": "The ASA to claim"
94
}
95
],
96
"returns": {
97
"type": "void"
98
}
99
},
100
{
101
"name": "arc59_reject",
102
"desc": "Reject the ASA by closing it out to the ASA creator. Always sends two inner transactions.All non-MBR ALGO balance in the inbox will be sent to the caller.",
103
"args": [
104
{
105
"name": "asa",
106
"type": "uint64",
107
"desc": "The ASA to reject"
108
}
109
],
110
"returns": {
111
"type": "void"
112
}
113
},
114
{
115
"name": "arc59_getInbox",
116
"desc": "Get the inbox address for the given receiver",
117
"args": [
118
{
119
"name": "receiver",
120
"type": "address",
121
"desc": "The receiver to get the inbox for"
122
}
123
],
124
"returns": {
125
"type": "address",
126
"desc": "Zero address if the receiver does not yet have an inbox, otherwise the inbox address"
127
}
128
},
129
{
130
"name": "arc59_claimAlgo",
131
"desc": "Claim any extra algo from the inbox",
132
"args": [],
133
"returns": {
134
"type": "void"
135
}
136
}
137
]
138
}

Sending an Asset

When sending an asset, the sender SHOULD call ARC59_getSendAssetInfo to determine relevant information about the receiver and the router. This information is included as a tuple described below

IndexObject PropertyDescriptionType
0itxnsThe number of itxns requireduint64
1mbrThe amount of ALGO the sender MUST send the the router contract to cover MBRuint64
2routerOptedInWhether the router is already opted in to the assetbool
3receiverOptedInWhether the receiver is already directly opted in to the assetbool
4receiverAlgoNeededForClaimThe amount of ALGO the receiver would currently need to claim the assetuint64

This information can then be used to send the asset. An example of using this information to send an asset is shown in the reference implementation section.

Claiming an Asset

When claiming an asset, the claimer MUST call arc59_claim to claim the asset from their inbox. This will transfer the asset to the claimer and any extra ALGO in the inbox will be sent to the claimer.

Prior to sending the arc59_claim app call, a call to arc59_claimAlgo SHOULD be made to claim any extra ALGO in the inbox if the inbox balance is above its minimum balance.

An example of claiming an asset is shown in the reference implementation section.

Rationale

This design was created to offer a standard mechanism by which wallets, explorers, and dapps could enable users to send, receive, and find claimable ASAs without requiring any changes to the core protocol.

This ARC is intended to replace ARC-12. This ARC is simpler than ARC-12, with the main feature lost being senders not getting back MBR. Given the significant reduction in complexity it is considered to be worth the tradeoff. No way to get back MBR is also another way to disincentivize spam.

Rejection

The initial proposal for this ARC included a method for burning that leveraged ARC-54. After further consideration though it was decided to remove the burn functionality with a reject method. The reject method does not burn the ASA. It simply closes out to the creator. This decision was made to reduce the additional complexity and potential user friction that ARC-54 opt-ins introduced.

Router MBR

It should be noted that the MBR for the router contract itself is non-recoverable. This was an intentional decision that results in more predictable costs for assets that may freuqently be sent through the router, such as stablecoins.

Test Cases

Test cases for the JavaScript client and the ARC-59 smart contract implementation can be found here

Reference Implementation

A project with a the full reference implementation, including the smart contract and JavaScript library (used for testing), can be found here.

Router Contract

This contract is written using TEALScript v0.90.3

1
/* eslint-disable max-classes-per-file */
2
3
// eslint-disable-next-line import/no-unresolved, import/extensions
4
import { Contract } from "@algorandfoundation/tealscript";
5
6
type SendAssetInfo = {
7
/**
8
* The total number of inner transactions required to send the asset through the router.
9
* This should be used to add extra fees to the app call
10
*/
11
itxns: uint64;
12
/** The total MBR the router needs to send the asset through the router. */
13
mbr: uint64;
14
/** Whether the router is already opted in to the asset or not */
15
routerOptedIn: boolean;
16
/** Whether the receiver is already directly opted in to the asset or not */
17
receiverOptedIn: boolean;
18
/** The amount of ALGO the receiver would currently need to claim the asset */
19
receiverAlgoNeededForClaim: uint64;
20
};
21
22
class ControlledAddress extends Contract {
23
@allow.create("DeleteApplication")
24
new(): Address {
25
sendPayment({
26
rekeyTo: this.txn.sender,
27
});
28
29
return this.app.address;
30
}
31
}
32
33
export class ARC59 extends Contract {
34
inboxes = BoxMap<Address, Address>();
35
36
/**
37
* Deploy ARC59 contract
38
*
39
*/
40
createApplication(): void {}
41
42
/**
43
* Opt the ARC59 router into the ASA. This is required before this app can be used to send the ASA to anyone.
44
*
45
* @param asa The ASA to opt into
46
*/
47
arc59_optRouterIn(asa: AssetID): void {
48
sendAssetTransfer({
49
assetReceiver: this.app.address,
50
assetAmount: 0,
51
xferAsset: asa,
52
});
53
}
54
55
/**
56
* Gets the existing inbox for the receiver or creates a new one if it does not exist
57
*
58
* @param receiver The address to get or create the inbox for
59
* @returns The inbox address
60
*/
61
arc59_getOrCreateInbox(receiver: Address): Address {
62
if (this.inboxes(receiver).exists) return this.inboxes(receiver).value;
63
64
const inbox = sendMethodCall<typeof ControlledAddress.prototype.new>({
65
onCompletion: OnCompletion.DeleteApplication,
66
approvalProgram: ControlledAddress.approvalProgram(),
67
clearStateProgram: ControlledAddress.clearProgram(),
68
});
69
70
this.inboxes(receiver).value = inbox;
71
72
return inbox;
73
}
74
75
/**
76
*
77
* @param receiver The address to send the asset to
78
* @param asset The asset to send
79
*
80
* @returns Returns the following information for sending an asset:
81
* The number of itxns required, the MBR required, whether the router is opted in, whether the receiver is opted in,
82
* and how much ALGO the receiver would need to claim the asset
83
*/
84
arc59_getSendAssetInfo(receiver: Address, asset: AssetID): SendAssetInfo {
85
const routerOptedIn = this.app.address.isOptedInToAsset(asset);
86
const receiverOptedIn = receiver.isOptedInToAsset(asset);
87
const info: SendAssetInfo = {
88
itxns: 1,
89
mbr: 0,
90
routerOptedIn: routerOptedIn,
91
receiverOptedIn: receiverOptedIn,
92
receiverAlgoNeededForClaim: 0,
93
};
94
95
if (receiverOptedIn) return info;
96
97
const algoNeededToClaim =
98
receiver.minBalance + globals.assetOptInMinBalance + globals.minTxnFee;
99
100
// Determine how much ALGO the receiver needs to claim the asset
101
if (receiver.balance < algoNeededToClaim) {
102
info.receiverAlgoNeededForClaim += algoNeededToClaim - receiver.balance;
103
}
104
105
// Add mbr and transaction for opting the router in
106
if (!routerOptedIn) {
107
info.mbr += globals.assetOptInMinBalance;
108
info.itxns += 1;
109
}
110
111
if (!this.inboxes(receiver).exists) {
112
// Two itxns to create inbox (create + rekey)
113
// One itxns to send MBR
114
// One itxn to opt in
115
info.itxns += 4;
116
117
// Calculate the MBR for the inbox box
118
const preMBR = globals.currentApplicationAddress.minBalance;
119
this.inboxes(receiver).value = globals.zeroAddress;
120
const boxMbrDelta = globals.currentApplicationAddress.minBalance - preMBR;
121
this.inboxes(receiver).delete();
122
123
// MBR = MBR for the box + min balance for the inbox + ASA MBR
124
info.mbr +=
125
boxMbrDelta + globals.minBalance + globals.assetOptInMinBalance;
126
127
return info;
128
}
129
130
const inbox = this.inboxes(receiver).value;
131
132
if (!inbox.isOptedInToAsset(asset)) {
133
// One itxn to opt in
134
info.itxns += 1;
135
136
if (!(inbox.balance >= inbox.minBalance + globals.assetOptInMinBalance)) {
137
// One itxn to send MBR
138
info.itxns += 1;
139
140
// MBR = ASA MBR
141
info.mbr += globals.assetOptInMinBalance;
142
}
143
}
144
145
return info;
146
}
147
148
/**
149
* Send an asset to the receiver
150
*
151
* @param receiver The address to send the asset to
152
* @param axfer The asset transfer to this app
153
* @param additionalReceiverFunds The amount of ALGO to send to the receiver/inbox in addition to the MBR
154
*
155
* @returns The address that the asset was sent to (either the receiver or their inbox)
156
*/
157
arc59_sendAsset(
158
axfer: AssetTransferTxn,
159
receiver: Address,
160
additionalReceiverFunds: uint64
161
): Address {
162
verifyAssetTransferTxn(axfer, {
163
assetReceiver: this.app.address,
164
});
165
166
// If the receiver is opted in, send directly to their account
167
if (receiver.isOptedInToAsset(axfer.xferAsset)) {
168
sendAssetTransfer({
169
assetReceiver: receiver,
170
assetAmount: axfer.assetAmount,
171
xferAsset: axfer.xferAsset,
172
});
173
174
if (additionalReceiverFunds !== 0) {
175
sendPayment({
176
receiver: receiver,
177
amount: additionalReceiverFunds,
178
});
179
}
180
181
return receiver;
182
}
183
184
const inboxExisted = this.inboxes(receiver).exists;
185
const inbox = this.arc59_getOrCreateInbox(receiver);
186
187
if (additionalReceiverFunds !== 0) {
188
sendPayment({
189
receiver: inbox,
190
amount: additionalReceiverFunds,
191
});
192
}
193
194
if (!inbox.isOptedInToAsset(axfer.xferAsset)) {
195
let inboxMbrDelta = globals.assetOptInMinBalance;
196
if (!inboxExisted) inboxMbrDelta += globals.minBalance;
197
198
// Ensure the inbox has enough balance to opt in
199
if (inbox.balance < inbox.minBalance + inboxMbrDelta) {
200
sendPayment({
201
receiver: inbox,
202
amount: inboxMbrDelta,
203
});
204
}
205
206
// Opt the inbox in
207
sendAssetTransfer({
208
sender: inbox,
209
assetReceiver: inbox,
210
assetAmount: 0,
211
xferAsset: axfer.xferAsset,
212
});
213
}
214
215
// Transfer the asset to the inbox
216
sendAssetTransfer({
217
assetReceiver: inbox,
218
assetAmount: axfer.assetAmount,
219
xferAsset: axfer.xferAsset,
220
});
221
222
return inbox;
223
}
224
225
/**
226
* Claim an ASA from the inbox
227
*
228
* @param asa The ASA to claim
229
*/
230
arc59_claim(asa: AssetID): void {
231
const inbox = this.inboxes(this.txn.sender).value;
232
233
sendAssetTransfer({
234
sender: inbox,
235
assetReceiver: this.txn.sender,
236
assetAmount: inbox.assetBalance(asa),
237
xferAsset: asa,
238
assetCloseTo: this.txn.sender,
239
});
240
241
sendPayment({
242
sender: inbox,
243
receiver: this.txn.sender,
244
amount: inbox.balance - inbox.minBalance,
245
});
246
}
247
248
/**
249
* Reject the ASA by closing it out to the ASA creator. Always sends two inner transactions.
250
* All non-MBR ALGO balance in the inbox will be sent to the caller.
251
*
252
* @param asa The ASA to reject
253
*/
254
arc59_reject(asa: AssetID) {
255
const inbox = this.inboxes(this.txn.sender).value;
256
257
sendAssetTransfer({
258
sender: inbox,
259
assetReceiver: asa.creator,
260
assetAmount: inbox.assetBalance(asa),
261
xferAsset: asa,
262
assetCloseTo: asa.creator,
263
});
264
265
sendPayment({
266
sender: inbox,
267
receiver: this.txn.sender,
268
amount: inbox.balance - inbox.minBalance,
269
});
270
}
271
272
/**
273
* Get the inbox address for the given receiver
274
*
275
* @param receiver The receiver to get the inbox for
276
*
277
* @returns Zero address if the receiver does not yet have an inbox, otherwise the inbox address
278
*/
279
arc59_getInbox(receiver: Address): Address {
280
return this.inboxes(receiver).exists
281
? this.inboxes(receiver).value
282
: globals.zeroAddress;
283
}
284
285
/** Claim any extra algo from the inbox */
286
arc59_claimAlgo() {
287
const inbox = this.inboxes(this.txn.sender).value;
288
289
assert(inbox.balance - inbox.minBalance !== 0);
290
291
sendPayment({
292
sender: inbox,
293
receiver: this.txn.sender,
294
amount: inbox.balance - inbox.minBalance,
295
});
296
}
297
}

TypeScript Send Asset Function

1
/**
2
* Send an asset to a receiver using the ARC59 router
3
*
4
* @param appClient The ARC59 client generated by algokit
5
* @param assetId The ID of the asset to send
6
* @param sender The address of the sender
7
* @param receiver The address of the receiver
8
* @param algorand The AlgorandClient instance to use to send transactions
9
* @param sendAlgoForNewAccount Whether to send 201_000 uALGO to the receiver so they can claim the asset with a 0-ALGO balance
10
*/
11
async function arc59SendAsset(
12
appClient: Arc59Client,
13
assetId: bigint,
14
sender: string,
15
receiver: string,
16
algorand: algokit.AlgorandClient
17
) {
18
// Get the address of the ARC59 router
19
const arc59RouterAddress = (await appClient.appClient.getAppReference())
20
.appAddress;
21
22
// Call arc59GetSendAssetInfo to get the following:
23
// itxns - The number of transactions needed to send the asset
24
// mbr - The minimum balance that must be sent to the router
25
// routerOptedIn - Whether the router has opted in to the asset
26
// receiverOptedIn - Whether the receiver has opted in to the asset
27
const [
28
itxns,
29
mbr,
30
routerOptedIn,
31
receiverOptedIn,
32
receiverAlgoNeededForClaim,
33
] = (await appClient.arc59GetSendAssetInfo({ asset: assetId, receiver }))
34
.return!;
35
36
// If the receiver has opted in, just send the asset directly
37
if (receiverOptedIn) {
38
await algorand.send.assetTransfer({
39
sender,
40
receiver,
41
assetId,
42
amount: 1n,
43
});
44
45
return;
46
}
47
48
// Create a composer to form an atomic transaction group
49
const composer = appClient.compose();
50
51
const signer = algorand.account.getSigner(sender);
52
53
// If the MBR is non-zero, send the MBR to the router
54
if (mbr || receiverAlgoNeededForClaim) {
55
const mbrPayment = await algorand.transactions.payment({
56
sender,
57
receiver: arc59RouterAddress,
58
amount: algokit.microAlgos(Number(mbr + receiverAlgoNeededForClaim)),
59
});
60
61
composer.addTransaction({ txn: mbrPayment, signer });
62
}
63
64
// If the router is not opted in, add a call to arc59OptRouterIn to do so
65
if (!routerOptedIn) composer.arc59OptRouterIn({ asa: assetId });
66
67
/** The box of the receiver's pubkey will always be needed */
68
const boxes = [algosdk.decodeAddress(receiver).publicKey];
69
70
/** The address of the receiver's inbox */
71
const inboxAddress = (
72
await appClient.compose().arc59GetInbox({ receiver }, { boxes }).simulate()
73
).returns[0];
74
75
// The transfer of the asset to the router
76
const axfer = await algorand.transactions.assetTransfer({
77
sender,
78
receiver: arc59RouterAddress,
79
assetId,
80
amount: 1n,
81
});
82
83
// An extra itxn is if we are also sending ALGO for the receiver claim
84
const totalItxns = itxns + (receiverAlgoNeededForClaim === 0n ? 0n : 1n);
85
86
composer.arc59SendAsset(
87
{ axfer, receiver, additionalReceiverFunds: receiverAlgoNeededForClaim },
88
{
89
sendParams: { fee: algokit.microAlgos(1000 + 1000 * Number(totalItxns)) },
90
boxes, // The receiver's pubkey
91
// Always good to include both accounts here, even if we think only the receiver is needed. This is to help protect against race conditions within a block.
92
accounts: [receiver, inboxAddress],
93
// Even though the asset is available in the group, we need to explicitly define it here because we will be checking the asset balance of the receiver
94
assets: [Number(assetId)],
95
}
96
);
97
98
// Disable resource population to ensure that our manually defined resources are correct
99
algokit.Config.configure({ populateAppCallResources: false });
100
101
// Send the transaction group
102
await composer.execute();
103
104
// Re-enable resource population
105
algokit.Config.configure({ populateAppCallResources: true });
106
}

TypeScript Claim Function

1
/**
2
* Claim an asset from the ARC59 inbox
3
*
4
* @param appClient The ARC59 client generated by algokit
5
* @param assetId The ID of the asset to claim
6
* @param claimer The address of the account claiming the asset
7
* @param algorand The AlgorandClient instance to use to send transactions
8
*/
9
async function arc59Claim(
10
appClient: Arc59Client,
11
assetId: bigint,
12
claimer: string,
13
algorand: algokit.AlgorandClient
14
) {
15
const composer = appClient.compose();
16
17
// Check if the claimer has opted in to the asset
18
let claimerOptedIn = false;
19
try {
20
await algorand.account.getAssetInformation(claimer, assetId);
21
claimerOptedIn = true;
22
} catch (e) {
23
// Do nothing
24
}
25
26
const inbox = (
27
await appClient
28
.compose()
29
.arc59GetInbox({ receiver: claimer })
30
.simulate({ allowUnnamedResources: true })
31
).returns[0];
32
33
let totalTxns = 3;
34
35
// If the inbox has extra ALGO, claim it
36
const inboxInfo = await algorand.account.getInformation(inbox);
37
if (inboxInfo.minBalance < inboxInfo.amount) {
38
totalTxns += 2;
39
composer.arc59ClaimAlgo(
40
{},
41
{
42
sender: algorand.account.getAccount(claimer),
43
sendParams: { fee: algokit.algos(0) },
44
}
45
);
46
}
47
48
// If the claimer hasn't already opted in, add a transaction to do so
49
if (!claimerOptedIn) {
50
composer.addTransaction({
51
txn: await algorand.transactions.assetOptIn({ assetId, sender: claimer }),
52
signer: algorand.account.getSigner(claimer),
53
});
54
}
55
56
composer.arc59Claim(
57
{ asa: assetId },
58
{
59
sender: algorand.account.getAccount(claimer),
60
sendParams: { fee: algokit.microAlgos(1000 * totalTxns) },
61
}
62
);
63
64
await composer.execute();
65
}

Security Considerations

The router application controls all user inboxes. If this contract is compromised, user assets might also be compromised.

Copyright and related rights waived via CCO.