From 56bbf54c3b642ae19d3b5ebccdceb757103fded7 Mon Sep 17 00:00:00 2001 From: JoowonYun Date: Wed, 5 Mar 2025 18:55:14 +0900 Subject: [PATCH 1/4] feat: support to WalletV5R1 --- contract/src/wallet/WalletV5R1Contract.kt | 253 ++++++++++++++++++++++ contract/test/wallet/WalletV5Example.kt | 67 ++++++ 2 files changed, 320 insertions(+) create mode 100644 contract/src/wallet/WalletV5R1Contract.kt create mode 100644 contract/test/wallet/WalletV5Example.kt diff --git a/contract/src/wallet/WalletV5R1Contract.kt b/contract/src/wallet/WalletV5R1Contract.kt new file mode 100644 index 00000000..294862b3 --- /dev/null +++ b/contract/src/wallet/WalletV5R1Contract.kt @@ -0,0 +1,253 @@ +package org.ton.contract.wallet + +import kotlinx.io.bytestring.ByteString +import org.ton.api.pk.PrivateKeyEd25519 +import org.ton.api.pub.PublicKeyEd25519 +import org.ton.bigint.BigInt +import org.ton.bigint.toBigInt +import org.ton.bigint.xor +import org.ton.bitstring.BitString +import org.ton.block.* +import org.ton.boc.BagOfCells +import org.ton.cell.Cell +import org.ton.cell.CellBuilder +import org.ton.cell.CellSlice +import org.ton.cell.buildCell +import org.ton.contract.exception.AccountNotInitializedException +import org.ton.hashmap.HashMapE +import org.ton.kotlin.account.Account +import org.ton.lite.client.LiteClient +import org.ton.tlb.CellRef +import org.ton.tlb.TlbConstructor +import org.ton.tlb.constructor.AnyTlbConstructor +import org.ton.tlb.storeTlb +import kotlin.io.encoding.Base64 + +public class WalletId ( + public val walletVersion: Int = 0, + public val subwalletNumber: Int, + public val networkGlobalId: Int, + public val workchain: Int, +) { + public fun serialize(): BigInt { + val context = CellBuilder.createCell { + storeUInt(1, 1) + storeUInt(workchain, 8) + storeUInt(walletVersion, 8) + storeUInt(subwalletNumber, 15) + endCell() + }.beginParse().loadInt(32) + + return networkGlobalId.toBigInt().xor(context) + } +} + +public class WalletV5R1Contract( + override val liteClient: LiteClient, + override val address: AddrStd, + public val walletId: WalletId +) : WalletContract { + public suspend fun getWalletData(walletId: WalletId): Data { + val data = + ((liteClient.getAccountState(address).account.value as? Account)?.storage?.state as? AccountActive)?.value?.data?.value?.value?.beginParse() + require(data != null) { throw AccountNotInitializedException(address) } + + val walletData = Data.loadTlb(data) + walletData.walletId = walletId + return walletData + } + + public suspend fun getWalletDataOrNull(walletId: WalletId): Data? = try { + getWalletData(walletId) + } catch (e: AccountNotInitializedException) { + null + } + + public suspend fun transfer( + privateKey: PrivateKeyEd25519, + walletData: Data?, + transfer: WalletTransfer + ) { + val seqno = walletData?.seqno ?: 0 + + val walletData = walletData ?: Data( + seqno, + privateKey.publicKey(), + walletId + ) + + val stateInit = if (walletData.seqno == 0) stateInit(walletData).load() else null + + val message = transferMessage( + address = address, + stateInit = stateInit, + privateKey = privateKey, + validUntil = Int.MAX_VALUE, + seqno = seqno, + walletId = walletData.walletId, + transfer + ) + + liteClient.sendMessage(message) + } + + public override suspend fun transfer( + privateKey: PrivateKeyEd25519, + transfer: WalletTransfer + ): Unit = transfer(privateKey, getWalletDataOrNull(walletId), transfer) + + public data class Data( + val seqno: Int, + val publicKey: PublicKeyEd25519, + var walletId: WalletId, + val plugins: HashMapE + ) { + public constructor(seqno: Int, publicKey: PublicKeyEd25519, walletId: WalletId) : this( + seqno, + publicKey, + walletId, + HashMapE.empty() + ) + + public companion object : TlbConstructor( + "wallet.v5r1.data seqno:uint32 public_key:bits256 plugins:(HashmapE 256 (Maybe ^Cell)) = WalletV5R1Data" + ) { + override fun loadTlb(cellSlice: CellSlice): Data { + val authAllow = cellSlice.loadUInt(1) + val seqno = cellSlice.loadUInt(32).toInt() + val storeWalletId = cellSlice.loadInt(32) + val publicKey = PublicKeyEd25519(ByteString(*cellSlice.loadBits(256).toByteArray())) + + // TODO wallet ID parsing + return Data(seqno, publicKey, WalletId(0,0,-237,0)) + } + + override fun storeTlb(cellBuilder: CellBuilder, value: Data) { + cellBuilder.storeUInt(1, 1) // is signature auth allowed + cellBuilder.storeUInt(value.seqno, 32) + cellBuilder.storeInt(value.walletId.serialize(), 32) + cellBuilder.storeBytes(value.publicKey.key.toByteArray()) + cellBuilder.storeBoolean(false) + } + } + } + + public companion object { + public val CODE: Cell by lazy(LazyThreadSafetyMode.PUBLICATION) { + BagOfCells( + Base64.decode("te6cckECFAEAAoEAART/APSkE/S88sgLAQIBIAINAgFIAwQC3NAg10nBIJFbj2Mg1wsfIIIQZXh0br0hghBzaW50vbCSXwPgghBleHRuuo60gCDXIQHQdNch+kAw+kT4KPpEMFi9kVvg7UTQgQFB1yH0BYMH9A5voTGRMOGAQNchcH/bPOAxINdJgQKAuZEw4HDiEA8CASAFDAIBIAYJAgFuBwgAGa3OdqJoQCDrkOuF/8AAGa8d9qJoQBDrkOuFj8ACAUgKCwAXsyX7UTQcdch1wsfgABGyYvtRNDXCgCAAGb5fD2omhAgKDrkPoCwBAvIOAR4g1wsfghBzaWduuvLgin8PAeaO8O2i7fshgwjXIgKDCNcjIIAg1yHTH9Mf0x/tRNDSANMfINMf0//XCgAK+QFAzPkQmiiUXwrbMeHywIffArNQB7Dy0IRRJbry4IVQNrry4Ib4I7vy0IgikvgA3gGkf8jKAMsfAc8Wye1UIJL4D95w2zzYEAP27aLt+wL0BCFukmwhjkwCIdc5MHCUIccAs44tAdcoIHYeQ2wg10nACPLgkyDXSsAC8uCTINcdBscSwgBSMLDy0InXTNc5MAGk6GwShAe78uCT10rAAPLgk+1V4tIAAcAAkVvg69csCBQgkXCWAdcsCBwS4lIQseMPINdKERITAJYB+kAB+kT4KPpEMFi68uCR7UTQgQFB1xj0BQSdf8jKAEAEgwf0U/Lgi44UA4MH9Fvy4Iwi1woAIW4Bs7Dy0JDiyFADzxYS9ADJ7VQAcjDXLAgkji0h8uCS0gDtRNDSAFETuvLQj1RQMJExnAGBAUDXIdcKAPLgjuLIygBYzxbJ7VST8sCN4gAQk1vbMeHXTNC01sNe") + ).first() + } + + public const val OP_SEND: Int = 0 + + public fun address(privateKey: PrivateKeyEd25519, walletId: WalletId): AddrStd { + val stateInitRef = stateInit(Data(0, privateKey.publicKey(), walletId)) // Initial sequence number is 0 + val hash = stateInitRef.hash() + return AddrStd(walletId.workchain, hash) + } + + public fun stateInit( + data: Data, + ): CellRef { + val dataCell = buildCell { + storeTlb(Data, data) + } + return CellRef( + StateInit(CODE, dataCell), + StateInit + ) + } + + public fun transferMessage( + address: MsgAddressInt, + stateInit: StateInit?, + privateKey: PrivateKeyEd25519, + validUntil: Int, + seqno: Int, + walletId: WalletId, + vararg transfers: WalletTransfer + ): Message { + val info = ExtInMsgInfo( + src = AddrNone, + dest = address, + importFee = Coins() + ) + + val maybeStateInit = + Maybe.of(stateInit?.let { + Either.of>( + null, + CellRef(value = it, StateInit) + ) + }) + + + val transferBody = createTransferMessageBody( + privateKey, + validUntil, + seqno, + walletId, + *transfers + ) + + val body = Either.of>(null, CellRef(value = transferBody, AnyTlbConstructor)) + return Message( + info = info, + init = maybeStateInit, + body = body + ) + } + + private fun createTransferMessageBody( + privateKey: PrivateKeyEd25519, + validUntil: Int, + seqno: Int, + walletId: WalletId, + vararg gifts: WalletTransfer + ): Cell { + val packed = packV5Actions(*gifts) + val unsignedBody = CellBuilder.createCell { + storeUInt(0x7369676E, 32) // MessageType.ext + storeUInt(walletId.serialize(), 32) + if(seqno == 0) { + storeUInt(0xFFFFFFFF, 32) + } else { + storeUInt(validUntil, 32) + } + storeUInt(seqno, 32) + storeBitString(packed.bits) + storeRefs(packed.refs) + } + + val signature = BitString(privateKey.sign(unsignedBody.hash().toByteArray())) + + return CellBuilder.createCell { + storeBitString(unsignedBody.bits) + storeRefs(unsignedBody.refs) + storeBitString(signature) + } + } + + private fun packV5Actions(vararg gifts: WalletTransfer): CellBuilder { + + var latestCell = Cell.empty() + for (gift in gifts) { + val intMsg = CellRef(gift.toMessageRelaxed(), MessageRelaxed.tlbCodec(AnyTlbConstructor)) + + latestCell = CellBuilder.createCell { + storeUInt(0x0ec3c86d, 32) // OUT_ACTION_SEND_MSG_TAG + storeUInt(gift.sendMode, 8) + storeRefs(latestCell) + storeRefs(intMsg.cell) + } + } + + return CellBuilder.beginCell().apply { + storeBoolean(true) + storeRef(latestCell) + storeBoolean(false) + } + } + } +} \ No newline at end of file diff --git a/contract/test/wallet/WalletV5Example.kt b/contract/test/wallet/WalletV5Example.kt new file mode 100644 index 00000000..ea2a2f14 --- /dev/null +++ b/contract/test/wallet/WalletV5Example.kt @@ -0,0 +1,67 @@ +package org.ton.contract.wallet + +import io.github.andreypfau.kotlinx.crypto.sha2.sha256 +import io.ktor.util.hex +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.ton.api.pk.PrivateKeyEd25519 +import org.ton.block.AddrStd +import org.ton.block.Coins +import org.ton.kotlin.account.Account +import kotlin.test.Test + +class WalletV5Example { + @Test + fun walletV5Example(): Unit = runBlocking { + val liteClient = liteClientTestnet() + + val pk = PrivateKeyEd25519(sha256("example-key".encodeToByteArray())) + + val walletID = WalletId(0, 0, -3, 0) + val contract = WalletV5R1Contract( + liteClient, + WalletV5R1Contract.address(pk, walletID), + walletID, + ) + val testnetNonBounceAddr = + contract.address.toString(userFriendly = true, testOnly = true, bounceable = false) + println("Wallet Address: $testnetNonBounceAddr") + + var accountState = liteClient.getAccountState(contract.address) + val account = accountState.account.value as? Account + if (account == null) { + println("Account $testnetNonBounceAddr not initialized") + return@runBlocking + } + + val balance = account.storage.balance.coins + println("Account balance: $balance toncoins") + + contract.transfer(pk) { + coins = Coins.Companion.ofNano(100) // 100 nanoton + destination = AddrStd("kf8ZzXwnCm23GeqkK8ekU0Dxzu_fiXqIYO48FElkd7rVnoix") + messageData = MessageData.text("Hello, World!") + } + + while (true) { + println("Wait for transaction to be processed...") + delay(6000) + val newAccountState = liteClient.getAccountState(contract.address) + if (newAccountState != accountState) { + accountState = newAccountState + println("Got new account state with last transaction: ${accountState.lastTransactionId}") + break + } + } + + val lastTransactionId = accountState.lastTransactionId + if (lastTransactionId == null) { + println("No transactions found") + return@runBlocking + } + + val transaction = liteClient.getTransactions(accountState.address, lastTransactionId, 1) + .first().transaction.value + println("Transaction: $lastTransactionId") + } +} \ No newline at end of file From f6617722c078812bb2202292d3e9eb96b56b018d Mon Sep 17 00:00:00 2001 From: JoowonYun Date: Tue, 13 May 2025 10:42:43 +0900 Subject: [PATCH 2/4] refactor: code convention (lint & const) --- contract/src/wallet/WalletV5R1Contract.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contract/src/wallet/WalletV5R1Contract.kt b/contract/src/wallet/WalletV5R1Contract.kt index 294862b3..f5a858df 100644 --- a/contract/src/wallet/WalletV5R1Contract.kt +++ b/contract/src/wallet/WalletV5R1Contract.kt @@ -140,6 +140,8 @@ public class WalletV5R1Contract( } public const val OP_SEND: Int = 0 + public const val MESSAGE_TYPE_EXT: Int = 0x7369676E + public const val OUT_ACTION_SEND_MSG_TAG: Int = 0x0ec3c86d public fun address(privateKey: PrivateKeyEd25519, walletId: WalletId): AddrStd { val stateInitRef = stateInit(Data(0, privateKey.publicKey(), walletId)) // Initial sequence number is 0 @@ -208,7 +210,7 @@ public class WalletV5R1Contract( ): Cell { val packed = packV5Actions(*gifts) val unsignedBody = CellBuilder.createCell { - storeUInt(0x7369676E, 32) // MessageType.ext + storeUInt(MESSAGE_TYPE_EXT, 32) // MessageType.ext storeUInt(walletId.serialize(), 32) if(seqno == 0) { storeUInt(0xFFFFFFFF, 32) @@ -236,7 +238,7 @@ public class WalletV5R1Contract( val intMsg = CellRef(gift.toMessageRelaxed(), MessageRelaxed.tlbCodec(AnyTlbConstructor)) latestCell = CellBuilder.createCell { - storeUInt(0x0ec3c86d, 32) // OUT_ACTION_SEND_MSG_TAG + storeUInt(OUT_ACTION_SEND_MSG_TAG, 32) // OUT_ACTION_SEND_MSG_TAG storeUInt(gift.sendMode, 8) storeRefs(latestCell) storeRefs(intMsg.cell) @@ -250,4 +252,4 @@ public class WalletV5R1Contract( } } } -} \ No newline at end of file +} From 56506f2de6194d0070c717e8a82b742de83d0630 Mon Sep 17 00:00:00 2001 From: JoowonYun Date: Tue, 13 May 2025 10:44:01 +0900 Subject: [PATCH 3/4] feat: support to walletID's loadTlb --- contract/src/wallet/WalletV5R1Contract.kt | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/contract/src/wallet/WalletV5R1Contract.kt b/contract/src/wallet/WalletV5R1Contract.kt index f5a858df..8796532d 100644 --- a/contract/src/wallet/WalletV5R1Contract.kt +++ b/contract/src/wallet/WalletV5R1Contract.kt @@ -115,11 +115,28 @@ public class WalletV5R1Contract( override fun loadTlb(cellSlice: CellSlice): Data { val authAllow = cellSlice.loadUInt(1) val seqno = cellSlice.loadUInt(32).toInt() - val storeWalletId = cellSlice.loadInt(32) + val serialized = cellSlice.loadInt(32) val publicKey = PublicKeyEd25519(ByteString(*cellSlice.loadBits(256).toByteArray())) - // TODO wallet ID parsing - return Data(seqno, publicKey, WalletId(0,0,-237,0)) + // Create context cell + val context = CellBuilder.createCell { + storeUInt(1, 1) + storeUInt(0, 8) // workchain + storeUInt(0, 8) // walletVersion + storeUInt(0, 15) // subwalletNumber + }.beginParse().loadInt(32) + + val networkGlobalId = serialized.xor(context) + + // Create walletId with default values and the extracted networkGlobalId + val walletId = WalletId( + walletVersion = 0, + subwalletNumber = 0, + networkGlobalId = networkGlobalId.toInt(), + workchain = 0 + ) + + return Data(seqno, publicKey, walletId) } override fun storeTlb(cellBuilder: CellBuilder, value: Data) { From cc40397539f327123499794d72c63ade73f83f86 Mon Sep 17 00:00:00 2001 From: JoowonYun Date: Tue, 13 May 2025 13:08:32 +0900 Subject: [PATCH 4/4] feat: use MessageLayout --- contract/src/wallet/WalletV5R1Contract.kt | 31 ++++++++++++++--------- contract/test/wallet/WalletV5Example.kt | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/contract/src/wallet/WalletV5R1Contract.kt b/contract/src/wallet/WalletV5R1Contract.kt index 8796532d..0478e748 100644 --- a/contract/src/wallet/WalletV5R1Contract.kt +++ b/contract/src/wallet/WalletV5R1Contract.kt @@ -16,9 +16,12 @@ import org.ton.cell.buildCell import org.ton.contract.exception.AccountNotInitializedException import org.ton.hashmap.HashMapE import org.ton.kotlin.account.Account +import org.ton.kotlin.cell.CellContext +import org.ton.kotlin.message.MessageLayout import org.ton.lite.client.LiteClient import org.ton.tlb.CellRef import org.ton.tlb.TlbConstructor +import org.ton.tlb.TlbStorer import org.ton.tlb.constructor.AnyTlbConstructor import org.ton.tlb.storeTlb import kotlin.io.encoding.Base64 @@ -193,15 +196,6 @@ public class WalletV5R1Contract( importFee = Coins() ) - val maybeStateInit = - Maybe.of(stateInit?.let { - Either.of>( - null, - CellRef(value = it, StateInit) - ) - }) - - val transferBody = createTransferMessageBody( privateKey, validUntil, @@ -210,11 +204,24 @@ public class WalletV5R1Contract( *transfers ) - val body = Either.of>(null, CellRef(value = transferBody, AnyTlbConstructor)) + val layout = MessageLayout.compute( + info = info, + init = stateInit, + body = transferBody, + bodyStorer = object : TlbStorer { + override fun storeTlb(builder: CellBuilder, value: Cell, context: CellContext) { + builder.storeBitString(value.bits) + builder.storeRefs(value.refs) + } + } + ) + return Message( info = info, - init = maybeStateInit, - body = body + init = stateInit, + body = transferBody, + bodyCodec = AnyTlbConstructor, + layout = layout ) } diff --git a/contract/test/wallet/WalletV5Example.kt b/contract/test/wallet/WalletV5Example.kt index ea2a2f14..f7d31aed 100644 --- a/contract/test/wallet/WalletV5Example.kt +++ b/contract/test/wallet/WalletV5Example.kt @@ -64,4 +64,4 @@ class WalletV5Example { .first().transaction.value println("Transaction: $lastTransactionId") } -} \ No newline at end of file +}