Skip to content

Commit bd713dc

Browse files
authored
Merge pull request #16 from algorandfoundation/feat-logic-sig
feat: implement stubs for LogicSig and arg op code
2 parents ed4fd56 + 2da18f4 commit bd713dc

25 files changed

+1808
-219
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Account, Bytes, Global, LogicSig, op, TransactionType, Txn, Uint64, uint64 } from '@algorandfoundation/algorand-typescript'
2+
import algosdk from 'algosdk'
3+
4+
export default class HashedTimeLockedLogicSig extends LogicSig {
5+
program(): boolean | uint64 {
6+
// Participants
7+
const sellerAddress = Bytes(algosdk.decodeAddress('6ZHGHH5Z5CTPCF5WCESXMGRSVK7QJETR63M3NY5FJCUYDHO57VTCMJOBGY').publicKey)
8+
const buyerAddress = Bytes(algosdk.decodeAddress('7Z5PWO2C6LFNQFGHWKSK5H47IQP5OJW2M3HA2QPXTY3WTNP5NU2MHBW27M').publicKey)
9+
const seller = Account(sellerAddress)
10+
const buyer = Account(buyerAddress)
11+
12+
// Contract parameters
13+
const feeLimit = Uint64(1000)
14+
const secretHash = Bytes.fromHex('2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b')
15+
const timeout = Uint64(3000)
16+
17+
// Transaction conditions
18+
const isPayment = Txn.typeEnum === TransactionType.Payment
19+
const isFeeAcceptable = Txn.fee < feeLimit
20+
const isNoCloseTo = Txn.closeRemainderTo === Global.zeroAddress
21+
const isNoRekey = Txn.rekeyTo === Global.zeroAddress
22+
23+
// Safety conditions
24+
const safetyConditions = isPayment && isNoCloseTo && isNoRekey
25+
26+
// Seller receives payment if correct secret is provided
27+
const isToSeller = Txn.receiver === seller
28+
const isSecretCorrect = op.sha256(op.arg(0)) === secretHash
29+
const sellerReceives = isToSeller && isSecretCorrect
30+
31+
// Buyer receives refund after timeout
32+
const isToBuyer = Txn.receiver === buyer
33+
const isAfterTimeout = Txn.firstValid > timeout
34+
const buyerReceivesRefund = isToBuyer && isAfterTimeout
35+
36+
// Final contract logic
37+
return isFeeAcceptable && safetyConditions && (sellerReceives || buyerReceivesRefund)
38+
}
39+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Account, Bytes } from '@algorandfoundation/algorand-typescript'
2+
import { TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
3+
import algosdk from 'algosdk'
4+
import { afterEach, describe, expect, it } from 'vitest'
5+
import { ZERO_ADDRESS } from '../../src/constants'
6+
import HashedTimeLockedLogicSig from './signature.algo'
7+
8+
describe('HTLC LogicSig', () => {
9+
const ctx = new TestExecutionContext()
10+
11+
afterEach(() => {
12+
ctx.reset()
13+
})
14+
15+
it('seller receives payment if correct secret is provided', () => {
16+
const receiverAddress = Bytes(algosdk.decodeAddress('6ZHGHH5Z5CTPCF5WCESXMGRSVK7QJETR63M3NY5FJCUYDHO57VTCMJOBGY').publicKey)
17+
ctx.txn
18+
.createScope([
19+
ctx.any.txn.payment({
20+
fee: 500,
21+
firstValid: 1000,
22+
closeRemainderTo: Account(ZERO_ADDRESS),
23+
rekeyTo: Account(ZERO_ADDRESS),
24+
receiver: Account(receiverAddress),
25+
}),
26+
])
27+
.execute(() => {
28+
const result = ctx.executeLogicSig(new HashedTimeLockedLogicSig(), Bytes('secret'))
29+
expect(result).toBe(true)
30+
})
31+
})
32+
})

examples/rollup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const config: RollupOptions = {
1212
'examples/auction/contract.algo.ts',
1313
'examples/voting/contract.algo.ts',
1414
'examples/simple-voting/contract.algo.ts',
15+
'examples/zk-whitelist/contract.algo.ts',
1516
],
1617
output: [
1718
{
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
abimethod,
3+
Account,
4+
arc4,
5+
assert,
6+
BigUint,
7+
Bytes,
8+
ensureBudget,
9+
Global,
10+
itxn,
11+
LocalState,
12+
op,
13+
OpUpFeeSource,
14+
TemplateVar,
15+
Txn,
16+
uint64,
17+
} from '@algorandfoundation/algorand-typescript'
18+
19+
const curveMod = 21888242871839275222246405745257275088548364400416034343698204186575808495617n
20+
const verifierBudget = 145000
21+
22+
export default class ZkWhitelistContract extends arc4.Contract {
23+
appName: arc4.Str | undefined
24+
whiteList = LocalState<boolean>()
25+
26+
@abimethod({ onCreate: 'require' })
27+
create(name: arc4.Str) {
28+
// Create the application
29+
this.appName = name
30+
}
31+
32+
@abimethod({ allowActions: ['UpdateApplication', 'DeleteApplication'] })
33+
update() {
34+
// Update the application if it is mutable (manager only)
35+
assert(Global.creatorAddress === Txn.sender)
36+
}
37+
38+
@abimethod({ allowActions: ['OptIn', 'CloseOut'] })
39+
optInOrOut() {
40+
// Opt in or out of the application
41+
return
42+
}
43+
44+
@abimethod()
45+
addAddressToWhitelist(address: arc4.Address, proof: arc4.DynamicArray<arc4.Address>): arc4.Str {
46+
/*
47+
Add caller to the whitelist if the zk proof is valid.
48+
On success, will return an empty string. Otherwise, will return an error
49+
message.
50+
*/
51+
ensureBudget(verifierBudget, OpUpFeeSource.GroupCredit)
52+
// The verifier expects public inputs to be in the curve field, but an
53+
// Algorand address might represent a number larger than the field
54+
// modulus, so to be safe we take the address modulo the field modulus
55+
const addressMod = arc4.interpretAsArc4<arc4.Address>(op.bzero(32).bitwiseOr(Bytes(BigUint(address.bytes) % curveMod)))
56+
// Verify the proof by calling the deposit verifier app
57+
const verified = this.verifyProof(TemplateVar<uint64>('VERIFIER_APP_ID'), proof, new arc4.DynamicArray(addressMod))
58+
if (!verified.native) {
59+
return new arc4.Str('Proof verification failed')
60+
}
61+
// if successful, add the sender to the whitelist by setting local state
62+
const account = Account(address.bytes)
63+
if (Txn.sender !== account) {
64+
return new arc4.Str('Sender address does not match authorized address')
65+
}
66+
this.whiteList(account).value = true
67+
return new arc4.Str('')
68+
}
69+
70+
@abimethod()
71+
isOnWhitelist(address: arc4.Address): arc4.Bool {
72+
// Check if an address is on the whitelist
73+
const account = address.native
74+
const optedIn = op.appOptedIn(account, Global.currentApplicationId)
75+
if (!optedIn) {
76+
return new arc4.Bool(false)
77+
}
78+
const whitelisted = this.whiteList(account).value
79+
return new arc4.Bool(whitelisted)
80+
}
81+
82+
verifyProof(appId: uint64, proof: arc4.DynamicArray<arc4.Address>, publicInputs: arc4.DynamicArray<arc4.Address>): arc4.Bool {
83+
// Verify a proof using the verifier app.
84+
const verified = itxn
85+
.applicationCall({
86+
appId: appId,
87+
fee: 0,
88+
appArgs: [arc4.methodSelector('verify(byte[32][],byte[32][])bool'), proof.copy(), publicInputs.copy()],
89+
onCompletion: arc4.OnCompleteAction.NoOp,
90+
})
91+
.submit().lastLog
92+
return arc4.interpretAsArc4<arc4.Bool>(verified, 'log')
93+
}
94+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { arc4, Bytes } from '@algorandfoundation/algorand-typescript'
2+
import { TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
3+
import { afterEach, describe, expect, it } from 'vitest'
4+
import { ABI_RETURN_VALUE_LOG_PREFIX } from '../../src/constants'
5+
import ZkWhitelistContract from './contract.algo'
6+
7+
describe('ZK Whitelist', () => {
8+
const ctx = new TestExecutionContext()
9+
10+
afterEach(() => {
11+
ctx.reset()
12+
})
13+
14+
it('should be able to add address to whitelist', () => {
15+
// Arrange
16+
const contract = ctx.contract.create(ZkWhitelistContract)
17+
contract.create(ctx.any.arc4.str(10))
18+
19+
const address = new arc4.Address(ctx.defaultSender)
20+
const proof = new arc4.DynamicArray<arc4.Address>(new arc4.Address(Bytes(new Uint8Array(Array(32).fill(0)))))
21+
22+
const dummyVerifierApp = ctx.any.application({ appLogs: [ABI_RETURN_VALUE_LOG_PREFIX.concat(Bytes.fromHex('80'))] })
23+
ctx.setTemplateVar('VERIFIER_APP_ID', dummyVerifierApp.id)
24+
25+
// Act
26+
const result = contract.addAddressToWhitelist(address, proof)
27+
28+
// Assert
29+
expect(result.native).toEqual('')
30+
expect(contract.whiteList(ctx.defaultSender).value).toEqual(true)
31+
})
32+
33+
it('returns error message if proof verification fails', () => {
34+
// Arrange
35+
const contract = ctx.contract.create(ZkWhitelistContract)
36+
contract.create(ctx.any.arc4.str(10))
37+
38+
const address = ctx.any.arc4.address()
39+
const proof = new arc4.DynamicArray<arc4.Address>(new arc4.Address(Bytes(new Uint8Array(Array(32).fill(0)))))
40+
const dummyVerifierApp = ctx.any.application({ appLogs: [ABI_RETURN_VALUE_LOG_PREFIX.concat(Bytes(''))] })
41+
ctx.setTemplateVar('VERIFIER_APP_ID', dummyVerifierApp.id)
42+
43+
// Act
44+
const result = contract.addAddressToWhitelist(address, proof)
45+
46+
// Assert
47+
expect(result.native).toEqual('Proof verification failed')
48+
})
49+
50+
it('returns true if address is already in whitelist', () => {
51+
// Arrange
52+
const contract = ctx.contract.create(ZkWhitelistContract)
53+
contract.create(ctx.any.arc4.str(10))
54+
55+
const dummyAccount = ctx.any.account({ optedApplications: [ctx.ledger.getApplicationForContract(contract)] })
56+
contract.whiteList(dummyAccount).value = true
57+
58+
// Act
59+
const result = contract.isOnWhitelist(new arc4.Address(dummyAccount))
60+
61+
// Assert
62+
expect(result.native).toBe(true)
63+
})
64+
65+
it('returns false if address is not in whitelist', () => {
66+
// Arrange
67+
const contract = ctx.contract.create(ZkWhitelistContract)
68+
contract.create(ctx.any.arc4.str(10))
69+
70+
const dummyAccount = ctx.any.account({ optedApplications: [ctx.ledger.getApplicationForContract(contract)] })
71+
contract.whiteList(dummyAccount).value = false
72+
73+
// Act
74+
const result = contract.isOnWhitelist(new arc4.Address(dummyAccount))
75+
76+
// Assert
77+
expect(result.native).toBe(false)
78+
})
79+
})

package-lock.json

Lines changed: 6 additions & 5 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
@@ -63,8 +63,8 @@
6363
"tslib": "^2.6.2"
6464
},
6565
"dependencies": {
66-
"@algorandfoundation/algorand-typescript": "^0.0.1-alpha.22",
67-
"@algorandfoundation/puya-ts": "^1.0.0-alpha.34",
66+
"@algorandfoundation/algorand-typescript": "^0.0.1-alpha.23",
67+
"@algorandfoundation/puya-ts": "^1.0.0-alpha.35",
6868
"elliptic": "^6.5.7",
6969
"js-sha256": "^0.11.0",
7070
"js-sha3": "^0.9.3",

src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { Bytes } from '@algorandfoundation/algorand-typescript'
33
export const UINT64_SIZE = 64
44
export const UINT512_SIZE = 512
55
export const MAX_UINT8 = 2 ** 8 - 1
6+
export const MAX_UINT16 = 2 ** 16 - 1
7+
export const MAX_UINT32 = 2 ** 32 - 1
68
export const MAX_UINT64 = 2n ** 64n - 1n
9+
export const MAX_UINT128 = 2n ** 128n - 1n
10+
export const MAX_UINT256 = 2n ** 256n - 1n
711
export const MAX_UINT512 = 2n ** 512n - 1n
812
export const MAX_BYTES_SIZE = 4096
913
export const MAX_LOG_SIZE = 1024
@@ -43,6 +47,7 @@ export const ABI_RETURN_VALUE_LOG_PREFIX = Bytes.fromHex('151F7C75')
4347

4448
export const UINT64_OVERFLOW_UNDERFLOW_MESSAGE = 'Uint64 overflow or underflow'
4549
export const BIGUINT_OVERFLOW_UNDERFLOW_MESSAGE = 'BigUint overflow or underflow'
50+
export const DEFAULT_TEMPLATE_VAR_PREFIX = 'TMPL_'
4651

4752
export const APP_ID_PREFIX = 'appID'
4853
export const HASH_BYTES_LENGTH = 32

src/impl/acct-params.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Account, gtxn, internal, uint64 } from '@algorandfoundation/algorand-typescript'
1+
import { Account, Application, gtxn, internal, uint64 } from '@algorandfoundation/algorand-typescript'
22
import { lazyContext } from '../context-helpers/internal-context'
33
import { asMaybeUint64Cls } from '../util'
4+
import { getApp } from './app-params'
45

56
export const getAccount = (acct: Account | internal.primitives.StubUint64Compat): Account => {
67
const acctId = asMaybeUint64Cls(acct)
@@ -21,6 +22,19 @@ export const minBalance = (a: Account | internal.primitives.StubUint64Compat): u
2122
return acct.minBalance
2223
}
2324

25+
export const appOptedIn = (
26+
a: Account | internal.primitives.StubUint64Compat,
27+
b: Application | internal.primitives.StubUint64Compat,
28+
): boolean => {
29+
const account = getAccount(a)
30+
const app = getApp(b)
31+
32+
if (account === undefined || app === undefined) {
33+
return false
34+
}
35+
return account.isOptedIn(app)
36+
}
37+
2438
export const AcctParams: internal.opTypes.AcctParamsType = {
2539
acctBalance(a: Account | internal.primitives.StubUint64Compat): readonly [uint64, boolean] {
2640
const acct = getAccount(a)

src/impl/ensure-budget.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { OpUpFeeSource, uint64 } from '@algorandfoundation/algorand-typescript'
2+
3+
export function ensureBudgetImpl(_budget: uint64, _feeSource: OpUpFeeSource = OpUpFeeSource.GroupCredit) {
4+
// ensureBudget function is emulated to be a no-op
5+
}

0 commit comments

Comments
 (0)