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:
- Grants account holders fine-grained control over their holdings by allowing them to select which assets to allow and preventing receipt of unwanted tokens.
- 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
Index | Object Property | Description | Type |
---|---|---|---|
0 | itxns | The number of itxns required | uint64 |
1 | mbr | The amount of ALGO the sender MUST send the the router contract to cover MBR | uint64 |
2 | routerOptedIn | Whether the router is already opted in to the asset | bool |
3 | receiverOptedIn | Whether the receiver is already directly opted in to the asset | bool |
4 | receiverAlgoNeededForClaim | The amount of ALGO the receiver would currently need to claim the asset | uint64 |
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/extensions4import { Contract } from "@algorandfoundation/tealscript";5
6type 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 call10 */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
22class 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
33export class ARC59 extends Contract {34 inboxes = BoxMap<Address, Address>();35
36 /**37 * Deploy ARC59 contract38 *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 into46 */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 exist57 *58 * @param receiver The address to get or create the inbox for59 * @returns The inbox address60 */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 to78 * @param asset The asset to send79 *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 asset83 */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 asset101 if (receiver.balance < algoNeededToClaim) {102 info.receiverAlgoNeededForClaim += algoNeededToClaim - receiver.balance;103 }104
105 // Add mbr and transaction for opting the router in106 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 MBR114 // One itxn to opt in115 info.itxns += 4;116
117 // Calculate the MBR for the inbox box118 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 MBR124 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 in134 info.itxns += 1;135
136 if (!(inbox.balance >= inbox.minBalance + globals.assetOptInMinBalance)) {137 // One itxn to send MBR138 info.itxns += 1;139
140 // MBR = ASA MBR141 info.mbr += globals.assetOptInMinBalance;142 }143 }144
145 return info;146 }147
148 /**149 * Send an asset to the receiver150 *151 * @param receiver The address to send the asset to152 * @param axfer The asset transfer to this app153 * @param additionalReceiverFunds The amount of ALGO to send to the receiver/inbox in addition to the MBR154 *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: uint64161 ): Address {162 verifyAssetTransferTxn(axfer, {163 assetReceiver: this.app.address,164 });165
166 // If the receiver is opted in, send directly to their account167 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 in199 if (inbox.balance < inbox.minBalance + inboxMbrDelta) {200 sendPayment({201 receiver: inbox,202 amount: inboxMbrDelta,203 });204 }205
206 // Opt the inbox in207 sendAssetTransfer({208 sender: inbox,209 assetReceiver: inbox,210 assetAmount: 0,211 xferAsset: axfer.xferAsset,212 });213 }214
215 // Transfer the asset to the inbox216 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 inbox227 *228 * @param asa The ASA to claim229 */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 reject253 */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 receiver274 *275 * @param receiver The receiver to get the inbox for276 *277 * @returns Zero address if the receiver does not yet have an inbox, otherwise the inbox address278 */279 arc59_getInbox(receiver: Address): Address {280 return this.inboxes(receiver).exists281 ? this.inboxes(receiver).value282 : 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 router3 *4 * @param appClient The ARC59 client generated by algokit5 * @param assetId The ID of the asset to send6 * @param sender The address of the sender7 * @param receiver The address of the receiver8 * @param algorand The AlgorandClient instance to use to send transactions9 * @param sendAlgoForNewAccount Whether to send 201_000 uALGO to the receiver so they can claim the asset with a 0-ALGO balance10 */11async function arc59SendAsset(12 appClient: Arc59Client,13 assetId: bigint,14 sender: string,15 receiver: string,16 algorand: algokit.AlgorandClient17) {18 // Get the address of the ARC59 router19 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 asset24 // mbr - The minimum balance that must be sent to the router25 // routerOptedIn - Whether the router has opted in to the asset26 // receiverOptedIn - Whether the receiver has opted in to the asset27 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 directly37 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 group49 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 router54 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 so65 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 router76 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 claim84 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 pubkey91 // 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 receiver94 assets: [Number(assetId)],95 }96 );97
98 // Disable resource population to ensure that our manually defined resources are correct99 algokit.Config.configure({ populateAppCallResources: false });100
101 // Send the transaction group102 await composer.execute();103
104 // Re-enable resource population105 algokit.Config.configure({ populateAppCallResources: true });106}
TypeScript Claim Function
1/**2 * Claim an asset from the ARC59 inbox3 *4 * @param appClient The ARC59 client generated by algokit5 * @param assetId The ID of the asset to claim6 * @param claimer The address of the account claiming the asset7 * @param algorand The AlgorandClient instance to use to send transactions8 */9async function arc59Claim(10 appClient: Arc59Client,11 assetId: bigint,12 claimer: string,13 algorand: algokit.AlgorandClient14) {15 const composer = appClient.compose();16
17 // Check if the claimer has opted in to the asset18 let claimerOptedIn = false;19 try {20 await algorand.account.getAssetInformation(claimer, assetId);21 claimerOptedIn = true;22 } catch (e) {23 // Do nothing24 }25
26 const inbox = (27 await appClient28 .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 it36 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 so49 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
Copyright and related rights waived via CCO.