Skip to content

Commit d1c4260

Browse files
authored
Merge pull request #58 from algorandfoundation/fix/local-state
fix: ensure local state behaviour matches AVM
2 parents f072f85 + 87d4045 commit d1c4260

File tree

8 files changed

+310
-33
lines changed

8 files changed

+310
-33
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { Account, bytes, uint64 } from '@algorandfoundation/algorand-typescript'
2+
import { Bytes, Global, LocalState, Txn, arc4, assert, contract } from '@algorandfoundation/algorand-typescript'
3+
4+
/**
5+
* A contract demonstrating local storage functionality.
6+
* This contract shows how to use local state storage in an Algorand smart contract,
7+
* including initialization, reading, writing, and clearing of local state values.
8+
* Local state is per-account storage that requires accounts to opt-in before use.
9+
*
10+
* @stateTotals.localBytes - 4 bytes allocated for local byte storage
11+
* @stateTotals.localUints - 3 uints allocated for local integer storage
12+
*/
13+
@contract({ stateTotals: { localBytes: 4, localUints: 3 } })
14+
export default class LocalStorage extends arc4.Contract {
15+
// example: INIT_LOCAL_STATE
16+
public localInt = LocalState<uint64>({ key: 'int' })
17+
public localIntNoDefault = LocalState<uint64>()
18+
public localBytes = LocalState<bytes>()
19+
public localString = LocalState<string>()
20+
public localBool = LocalState<boolean>()
21+
public localAccount = LocalState<Account>()
22+
// example: INIT_LOCAL_STATE
23+
24+
// example: OPT_IN_TO_APPLICATION
25+
/**
26+
* Initializes local state values when an account opts into the application.
27+
* This method can only be called during an OptIn transaction.
28+
* Sets initial values for all local state variables:
29+
* - localInt: 100
30+
* - localIntNoDefault: 200
31+
* - localBytes: 'Silvio'
32+
* - localString: 'Micali'
33+
* - localBool: true
34+
* - localAccount: sender's address
35+
*/
36+
@arc4.abimethod({ allowActions: 'OptIn' })
37+
public optInToApplication(): void {
38+
this.localInt(Txn.sender).value = 100
39+
this.localIntNoDefault(Txn.sender).value = 200
40+
this.localBytes(Txn.sender).value = Bytes('Silvio')
41+
this.localString(Txn.sender).value = 'Micali'
42+
this.localBool(Txn.sender).value = true
43+
this.localAccount(Txn.sender).value = Txn.sender
44+
}
45+
// example: OPT_IN_TO_APPLICATION
46+
47+
// example: READ_LOCAL_STATE
48+
/**
49+
* Reads and returns all local state values for the transaction sender.
50+
* @returns A tuple containing:
51+
* - [0] uint64: The value of localInt
52+
* - [1] uint64: The value of localIntNoDefault
53+
* - [2] bytes: The value of localBytes
54+
* - [3] string: The value of localString
55+
* - [4] boolean: The value of localBool
56+
* - [5] Address: The value of localAccount converted to Address type
57+
*/
58+
@arc4.abimethod({ readonly: true })
59+
public readLocalState(): [uint64, uint64, bytes, string, boolean, arc4.Address] {
60+
const sender = Txn.sender
61+
// Convert Account reference type to native Address type for return value
62+
const accountAddress = new arc4.Address(this.localAccount(sender).value)
63+
64+
return [
65+
this.localInt(sender).value,
66+
this.localIntNoDefault(sender).value,
67+
this.localBytes(sender).value,
68+
this.localString(sender).value,
69+
this.localBool(sender).value,
70+
accountAddress,
71+
]
72+
}
73+
// example: READ_LOCAL_STATE
74+
75+
// example: WRITE_LOCAL_STATE
76+
/**
77+
* Updates multiple local state values for the transaction sender.
78+
* Requires the account to be opted into the application.
79+
* @param valueString - New string value to store
80+
* @param valueBool - New boolean value to store
81+
* @param valueAccount - New account address to store
82+
*/
83+
@arc4.abimethod()
84+
public writeLocalState(valueString: string, valueBool: boolean, valueAccount: Account): void {
85+
// Dynamic keys must be explicitly reserved in the contract's stateTotals configuration
86+
const sender = Txn.sender
87+
88+
assert(sender.isOptedIn(Global.currentApplicationId), 'Account must opt in to contract first')
89+
90+
this.localString(sender).value = valueString
91+
this.localBool(sender).value = valueBool
92+
this.localAccount(sender).value = valueAccount
93+
94+
assert(this.localString(sender).value === valueString)
95+
assert(this.localBool(sender).value === valueBool)
96+
assert(this.localAccount(sender).value === valueAccount)
97+
}
98+
// example: WRITE_LOCAL_STATE
99+
100+
// example: WRITE_DYNAMIC_LOCAL_STATE
101+
/**
102+
* Writes a value to local state using a dynamic key.
103+
* Demonstrates dynamic key-value storage in local state.
104+
* @param key - The dynamic key to store the value under
105+
* @param value - The string value to store
106+
* @returns The stored string value
107+
*/
108+
@arc4.abimethod()
109+
public writeDynamicLocalState(key: string, value: string): string {
110+
const sender = Txn.sender
111+
112+
assert(sender.isOptedIn(Global.currentApplicationId), 'Account must opt in to contract first')
113+
114+
const localDynamicAccess = LocalState<string>({ key })
115+
116+
localDynamicAccess(sender).value = value
117+
118+
assert(localDynamicAccess(sender).value === value)
119+
120+
return localDynamicAccess(sender).value
121+
}
122+
// example: WRITE_DYNAMIC_LOCAL_STATE
123+
124+
// example: READ_DYNAMIC_LOCAL_STATE
125+
/**
126+
* Reads a value from local state using a dynamic key.
127+
* @param key - The dynamic key to read the value from
128+
* @returns The stored string value for the given key
129+
*/
130+
@arc4.abimethod()
131+
public readDynamicLocalState(key: string): string {
132+
const sender = Txn.sender
133+
134+
assert(sender.isOptedIn(Global.currentApplicationId), 'Account must opt in to contract first')
135+
136+
const localDynamicAccess = LocalState<string>({ key })
137+
138+
assert(localDynamicAccess(sender).hasValue, 'Key not found')
139+
140+
return localDynamicAccess(sender).value
141+
}
142+
// example: READ_DYNAMIC_LOCAL_STATE
143+
144+
// example: CLEAR_LOCAL_STATE
145+
/**
146+
* Clears all local state values for the transaction sender.
147+
* After calling this method, all local state values will be deleted.
148+
*/
149+
@arc4.abimethod()
150+
public clearLocalState(): void {
151+
const sender = Txn.sender
152+
153+
assert(sender.isOptedIn(Global.currentApplicationId), 'Account must opt in to contract first')
154+
155+
this.localInt(sender).delete()
156+
this.localIntNoDefault(sender).delete()
157+
this.localBytes(sender).delete()
158+
this.localString(sender).delete()
159+
this.localBool(sender).delete()
160+
this.localAccount(sender).delete()
161+
}
162+
// example: CLEAR_LOCAL_STATE
163+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Bytes } from '@algorandfoundation/algorand-typescript'
2+
import { TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
3+
import { describe, expect, it } from 'vitest'
4+
import LocalStorage from './contract.algo'
5+
6+
describe('LocalStorage contract', () => {
7+
const ctx = new TestExecutionContext()
8+
9+
it('should initialize local state with correct values after opting in', () => {
10+
const contract = ctx.contract.create(LocalStorage)
11+
12+
contract.optInToApplication()
13+
14+
const [localInt, localIntNoDefault, localBytes, localString, localBool, localAddress] = contract.readLocalState()
15+
16+
expect(localInt.valueOf()).toBe(100)
17+
expect(localIntNoDefault.valueOf()).toBe(200)
18+
expect(localBytes.toString()).toBe(Bytes('Silvio').toString())
19+
expect(localString).toBe('Micali')
20+
expect(localBool).toBe(true)
21+
expect(localAddress.bytes.toString()).toBe(ctx.defaultSender.bytes.toString())
22+
})
23+
24+
it('should write and verify multiple local state values', () => {
25+
const contract = ctx.contract.create(LocalStorage)
26+
const account = ctx.any.account({ optedApplications: [ctx.ledger.getApplicationForContract(contract)] })
27+
28+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account, onCompletion: 'OptIn' })]).execute(() => {
29+
contract.optInToApplication()
30+
})
31+
32+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account })]).execute(() => {
33+
contract.writeLocalState('New String', false, account)
34+
35+
const [, , , localString, localBool, localAccount] = contract.readLocalState()
36+
37+
expect(localString).toBe('New String')
38+
expect(localBool).toBe(false)
39+
expect(localAccount.bytes).toEqual(account.bytes)
40+
})
41+
})
42+
43+
it('should write and read dynamic local state values', () => {
44+
const contract = ctx.contract.create(LocalStorage)
45+
const account = ctx.any.account({ optedApplications: [ctx.ledger.getApplicationForContract(contract)] })
46+
47+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account, onCompletion: 'OptIn' })]).execute(() => {
48+
contract.optInToApplication()
49+
})
50+
51+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account })]).execute(() => {
52+
const testKey = 'testKey'
53+
const testValue = 'testValue'
54+
55+
const writtenValue = contract.writeDynamicLocalState(testKey, testValue)
56+
expect(writtenValue).toBe(testValue)
57+
58+
const readValue = contract.readDynamicLocalState(testKey)
59+
expect(readValue).toBe(testValue)
60+
})
61+
})
62+
63+
it('should clear all local state values', () => {
64+
const contract = ctx.contract.create(LocalStorage)
65+
const account = ctx.any.account({ optedApplications: [ctx.ledger.getApplicationForContract(contract)] })
66+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account, onCompletion: 'OptIn' })]).execute(() => {
67+
contract.optInToApplication()
68+
})
69+
70+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account })]).execute(() => {
71+
contract.clearLocalState()
72+
73+
expect(() => contract.readLocalState()).toThrow()
74+
})
75+
})
76+
77+
it('should fail to write local state if not opted in', () => {
78+
const contract = ctx.contract.create(LocalStorage)
79+
const newAccount = ctx.defaultSender
80+
81+
expect(() => contract.writeLocalState('New String', false, newAccount)).toThrow('Account must opt in to contract first')
82+
})
83+
84+
it('should fail to read dynamic local state for non-existent key', () => {
85+
const contract = ctx.contract.create(LocalStorage)
86+
const account = ctx.any.account({ optedApplications: [ctx.ledger.getApplicationForContract(contract)] })
87+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account, onCompletion: 'OptIn' })]).execute(() => {
88+
contract.optInToApplication()
89+
})
90+
91+
ctx.txn.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: account })]).execute(() => {
92+
expect(() => contract.readDynamicLocalState('nonexistentKey')).toThrow('Key not found')
93+
})
94+
})
95+
})

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@
7171
"tslib": "^2.6.2"
7272
},
7373
"dependencies": {
74-
"@algorandfoundation/algorand-typescript": "^1.0.0-beta.16",
75-
"@algorandfoundation/puya-ts": "^1.0.0-beta.23",
74+
"@algorandfoundation/algorand-typescript": "^1.0.0-beta.17",
75+
"@algorandfoundation/puya-ts": "^1.0.0-beta.24",
7676
"elliptic": "^6.5.7",
7777
"js-sha256": "^0.11.0",
7878
"js-sha3": "^0.9.3",

src/impl/reference.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '@algorandfoundation/algorand-typescript'
99
import { encodingUtil } from '@algorandfoundation/puya-ts'
1010
import js_sha512 from 'js-sha512'
11+
import type { AccountMap } from '../collections/custom-key-map'
1112
import { BytesMap, Uint64Map } from '../collections/custom-key-map'
1213
import {
1314
ALGORAND_ADDRESS_BYTE_LENGTH,
@@ -26,7 +27,7 @@ import { asBigInt, asBytes, asUint64, asUint64Cls, asUint8Array, conactUint8Arra
2627
import { BytesBackedCls, Uint64BackedCls } from './base'
2728
import type { StubUint64Compat } from './primitives'
2829
import { Bytes, BytesCls, Uint64Cls } from './primitives'
29-
import type { GlobalStateCls } from './state'
30+
import type { GlobalStateCls, LocalStateCls } from './state'
3031

3132
export class AssetHolding {
3233
balance: uint64
@@ -133,6 +134,7 @@ export class ApplicationData {
133134
appLogs: bytes[]
134135
globalStates: BytesMap<GlobalStateCls<unknown>>
135136
localStates: BytesMap<LocalState<unknown>>
137+
localStateMaps: BytesMap<AccountMap<LocalStateCls<unknown>>>
136138
boxes: BytesMap<Uint8Array>
137139
}
138140

@@ -151,6 +153,7 @@ export class ApplicationData {
151153
appLogs: [],
152154
globalStates: new BytesMap(),
153155
localStates: new BytesMap(),
156+
localStateMaps: new BytesMap(),
154157
boxes: new BytesMap(),
155158
}
156159
}

src/impl/state.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,27 @@ export class LocalStateCls<ValueType> {
9191
}
9292

9393
export class LocalStateMapCls<ValueType> {
94-
#value = new AccountMap<LocalStateCls<ValueType>>()
94+
private applicationId: uint64
9595

96-
getValue(account: Account): LocalStateCls<ValueType> {
97-
if (!this.#value.has(account)) {
98-
this.#value.set(account, new LocalStateCls<ValueType>())
96+
constructor() {
97+
this.applicationId = lazyContext.activeGroup.activeApplicationId
98+
}
99+
100+
getValue(key: string | bytes | undefined, account: Account): LocalStateCls<ValueType> {
101+
const bytesKey = key === undefined ? Bytes() : asBytes(key)
102+
const localStateMap = this.ensureApplicationLocalStateMap(bytesKey)
103+
if (!localStateMap.has(account)) {
104+
localStateMap.set(account, new LocalStateCls())
105+
}
106+
return localStateMap.getOrFail(account) as LocalStateCls<ValueType>
107+
}
108+
109+
private ensureApplicationLocalStateMap(key: bytes | string) {
110+
const applicationData = lazyContext.ledger.applicationDataMap.getOrFail(this.applicationId)!.application
111+
if (!applicationData.localStateMaps.has(key)) {
112+
applicationData.localStateMaps.set(key, new AccountMap<LocalStateCls<ValueType>>())
99113
}
100-
return this.#value.getOrFail(account)!
114+
return applicationData.localStateMaps.getOrFail(key)
101115
}
102116
}
103117

@@ -107,7 +121,7 @@ export function GlobalState<ValueType>(options?: GlobalStateOptions<ValueType>):
107121

108122
export function LocalState<ValueType>(options?: { key?: bytes | string }): LocalStateType<ValueType> {
109123
function localStateInternal(account: Account): LocalStateForAccount<ValueType> {
110-
return localStateInternal.map.getValue(account)
124+
return localStateInternal.map.getValue(localStateInternal.key, account)
111125
}
112126
localStateInternal.key = options?.key
113127
localStateInternal.hasKey = options?.key !== undefined && options.key.length > 0

0 commit comments

Comments
 (0)