diff --git a/.gitignore b/.gitignore index 50e2fee..79c908f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ .cxx local.properties .swiftpm -.kotlin \ No newline at end of file +.kotlin +/.kotlin +/.kotlin/metadata diff --git a/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt b/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt index 9c065e4..2fc6a73 100644 --- a/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt +++ b/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt @@ -228,11 +228,7 @@ internal class SignerImpl( Crypto.checkHashAlgoForSigning(hashAlgo) } - override suspend fun sign(transaction: Transaction?, bytes: ByteArray): ByteArray { - TODO("Not yet implemented") - } - - override suspend fun sign(bytes: ByteArray): ByteArray { + override suspend fun sign(bytes: ByteArray, transaction: Transaction?): ByteArray { // check the private key is of the correct type val ecSK = if (privateKey.privateKey is ECPrivateKey) { privateKey.privateKey @@ -252,4 +248,4 @@ internal class SignerImpl( val curveOrderSize = Crypto.getCurveOrderSize(domain) return Crypto.formatSignature(RS[0], RS[1], curveOrderSize) } -} \ No newline at end of file +} diff --git a/flow/src/androidMain/kotlin/org/onflow/flow/infrastructure/scripts/ContractAddressRegister.kt b/flow/src/androidMain/kotlin/org/onflow/flow/infrastructure/scripts/ContractAddressRegister.kt index 307210c..1c9d42c 100644 --- a/flow/src/androidMain/kotlin/org/onflow/flow/infrastructure/scripts/ContractAddressRegister.kt +++ b/flow/src/androidMain/kotlin/org/onflow/flow/infrastructure/scripts/ContractAddressRegister.kt @@ -31,7 +31,7 @@ actual class ContractAddressRegister { } if (network != null) { - addresses[network] = contractAddresses.mapKeys { it.key.removePrefix("0x") }.toMutableMap() + addresses[network] = contractAddresses.mapKeys { it.key.removePrefix("0x").uppercase() }.toMutableMap() } else { println("Warning: Invalid network name: $networkStr") } @@ -58,7 +58,10 @@ actual class ContractAddressRegister { } actual fun getAddress(contract: String, network: ChainId): String? { - return addresses[network]?.get(contract) + val key = contract.removePrefix("0x") + val candidateKeys = listOf(key, key.uppercase(), key.lowercase()) + val map = addresses[network] ?: return null + return candidateKeys.mapNotNull { map[it.uppercase()] ?: map[it] }.firstOrNull() } actual fun getAddresses(network: ChainId): Map { @@ -76,9 +79,11 @@ actual class ContractAddressRegister { actual fun resolveImports(code: String, network: ChainId): String { var result = code getAddresses(network).forEach { (contract, address) -> - val pattern = "\\b0x${Regex.escape(contract)}\\b" - result = result.replace(Regex(pattern), address) + val normalized = contract.removePrefix("0x") + // Only replace 0x placeholders (case-insensitive), avoid bare "evm" or "EVM.foo" + val pattern = Regex("\\b0x${Regex.escape(normalized)}\\b", RegexOption.IGNORE_CASE) + result = result.replace(pattern, address) } return result } -} \ No newline at end of file +} diff --git a/flow/src/androidMain/resources/scripts/common/addresses.json b/flow/src/androidMain/resources/scripts/common/addresses.json index 3acde6d..ac2e180 100644 --- a/flow/src/androidMain/resources/scripts/common/addresses.json +++ b/flow/src/androidMain/resources/scripts/common/addresses.json @@ -38,4 +38,4 @@ "0xFlowIDTableStaking": "0x8624b52f9ddcd04a", "0xLockedTokens": "0x8d0e87b65159ae63" } -} \ No newline at end of file +} diff --git a/flow/src/androidMain/resources/scripts/common/child/get_child_account_meta.cdc b/flow/src/androidMain/resources/scripts/common/child/get_child_account_meta.cdc new file mode 100644 index 0000000..0c22ded --- /dev/null +++ b/flow/src/androidMain/resources/scripts/common/child/get_child_account_meta.cdc @@ -0,0 +1,18 @@ +import HybridCustody from 0xHYBRIDCUSTODY +import MetadataViews from 0xNONFUNGIBLETOKEN + +access(all) fun main(parent: Address): {Address: AnyStruct} { + let acct = getAuthAccount(parent) + let m = acct.storage.borrow<&HybridCustody.Manager>(from: HybridCustody.ManagerStoragePath) + + if m == nil { + return {} + } else { + var data: {Address: AnyStruct} = {} + for address in m?.getChildAddresses()! { + let c = m?.getChildAccountDisplay(address: address) + data.insert(key: address, c) + } + return data + } +} diff --git a/flow/src/androidMain/resources/scripts/common/child/get_child_addresses.cdc b/flow/src/androidMain/resources/scripts/common/child/get_child_addresses.cdc new file mode 100644 index 0000000..a6af1a6 --- /dev/null +++ b/flow/src/androidMain/resources/scripts/common/child/get_child_addresses.cdc @@ -0,0 +1,9 @@ +import HybridCustody from 0xHYBRIDCUSTODY + +access(all) fun main(parent: Address): [Address] { + let acct = getAuthAccount(parent) + if let manager = acct.storage.borrow<&HybridCustody.Manager>(from: HybridCustody.ManagerStoragePath) { + return manager.getChildAddresses() + } + return [] +} diff --git a/flow/src/androidMain/resources/scripts/common/evm/call_contract.cdc b/flow/src/androidMain/resources/scripts/common/evm/call_contract.cdc new file mode 100644 index 0000000..d22581f --- /dev/null +++ b/flow/src/androidMain/resources/scripts/common/evm/call_contract.cdc @@ -0,0 +1,40 @@ +import FungibleToken from 0xFUNGIBLETOKEN +import FlowToken from 0xFLOWTOKEN +import EVM from 0xEVM + +/// Transfers $FLOW from the signer's account Cadence Flow balance to the recipient's hex-encoded EVM address. +/// Note that a COA must have a $FLOW balance in EVM before transferring value to another EVM address. +/// +transaction(toEVMAddressHex: String, amount: UFix64, data: [UInt8], gasLimit: UInt64) { + + let coa: auth(EVM.Withdraw, EVM.Call) &EVM.CadenceOwnedAccount + let recipientEVMAddress: EVM.EVMAddress + + prepare(signer: auth(BorrowValue, SaveValue) &Account) { + if signer.storage.type(at: /storage/evm) == nil { + signer.storage.save(<-EVM.createCadenceOwnedAccount(), to: /storage/evm) + } + self.coa = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow reference to the signer's bridged account") + + self.recipientEVMAddress = EVM.addressFromString(toEVMAddressHex) + } + + execute { + if self.recipientEVMAddress.bytes == self.coa.address().bytes { + return + } + let valueBalance = EVM.Balance(attoflow: 0) + valueBalance.setFLOW(flow: amount) + let txResult = self.coa.call( + to: self.recipientEVMAddress, + data: data, + gasLimit: gasLimit, + value: valueBalance + ) + assert( + txResult.status == EVM.Status.failed || txResult.status == EVM.Status.successful, + message: "evm_error=".concat(txResult.errorMessage).concat("\n") + ) + } +} diff --git a/flow/src/androidMain/resources/scripts/common/evm/evm_run.cdc b/flow/src/androidMain/resources/scripts/common/evm/evm_run.cdc new file mode 100644 index 0000000..1218587 --- /dev/null +++ b/flow/src/androidMain/resources/scripts/common/evm/evm_run.cdc @@ -0,0 +1,19 @@ +import FungibleToken from 0xFUNGIBLETOKEN +import FlowToken from 0xFLOWTOKEN +import EVM from 0xEVM + +transaction(rlpEncodedTransaction: [UInt8], coinbaseAddr: String) { + + prepare(signer: auth(Storage, EVM.Withdraw) &Account) { + let coinbase = EVM.addressFromString(coinbaseAddr) + + let runResult = EVM.run(tx: rlpEncodedTransaction, coinbase: coinbase) + assert( + runResult.status == EVM.Status.successful, + message: "evm tx was not executed successfully." + ) + } + + execute { + } +} diff --git a/flow/src/androidMain/resources/scripts/common/staking/get_delegator_info.cdc b/flow/src/androidMain/resources/scripts/common/staking/get_delegator_info.cdc new file mode 100644 index 0000000..8c2b2e4 --- /dev/null +++ b/flow/src/androidMain/resources/scripts/common/staking/get_delegator_info.cdc @@ -0,0 +1,14 @@ +import FlowStakingCollection from 0xFLOWTABLESTAKING +import FlowIDTableStaking from 0xFLOWTABLESTAKING +import LockedTokens from 0xLOCKEDTOKENS + +access(all) fun main(address: Address): [FlowIDTableStaking.DelegatorInfo]? { + var res: [FlowIDTableStaking.DelegatorInfo]? = nil + + let inited = FlowStakingCollection.doesAccountHaveStakingCollection(address: address) + + if inited { + res = FlowStakingCollection.getAllDelegatorInfo(address: address) + } + return res +} diff --git a/flow/src/androidMain/resources/scripts/common/token/get_token_balance_storage.cdc b/flow/src/androidMain/resources/scripts/common/token/get_token_balance_storage.cdc new file mode 100644 index 0000000..3be0373 --- /dev/null +++ b/flow/src/androidMain/resources/scripts/common/token/get_token_balance_storage.cdc @@ -0,0 +1,35 @@ +import FungibleToken from 0xFUNGIBLETOKEN + +/// Queries for FT.Vault balance of all FT.Vaults in the specified account. +/// +access(all) fun main(address: Address): {String: UFix64} { + // Get the account + let account = getAuthAccount(address) + // Init for return value + let balances: {String: UFix64} = {} + // Track seen Types in array + let seen: [String] = [] + // Assign the type we'll need + let vaultType: Type = Type<@{FungibleToken.Vault}>() + // Iterate over all stored items & get the path if the type is what we're looking for + account.storage.forEachStored(fun (path: StoragePath, type: Type): Bool { + if !type.isRecovered && (type.isInstance(vaultType) || type.isSubtype(of: vaultType)) { + // Get a reference to the resource & its balance + let vaultRef = account.storage.borrow<&{FungibleToken.Balance}>(from: path)! + // Insert a new values if it's the first time we've seen the type + if !seen.contains(type.identifier) { + balances.insert(key: type.identifier, vaultRef.balance) + } else { + // Otherwise just update the balance of the vault (unlikely we'll see the same type twice in + // the same account, but we want to cover the case) + balances[type.identifier] = balances[type.identifier]! + vaultRef.balance + } + } + return true + }) + + // Add available Flow Token Balance + balances.insert(key: "availableFlowToken", account.availableBalance) + + return balances +} diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/AddressRegistry.kt b/flow/src/commonMain/kotlin/org/onflow/flow/AddressRegistry.kt index aaf7f75..31893c5 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/AddressRegistry.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/AddressRegistry.kt @@ -16,11 +16,12 @@ class AddressRegistry { const val NFT_STOREFRONT = "0xNFTSTOREFRONT" const val TOKEN_FORWARDING = "0xTOKENFORWARDING" const val EVM = "0xEVM" + const val HYBRID_CUSTODY = "0xHYBRIDCUSTODY" } private val SCRIPT_TOKEN_MAP: MutableMap> = mutableMapOf() - var defaultChainId = ChainId.Mainnet + var defaultChainId: ChainIdProvider = ChainId.Mainnet init { registerDefaults() @@ -84,7 +85,8 @@ class AddressRegistry { STAKING_PROXY to FlowAddress("0x7aad92e5a0715d21"), NON_FUNGIBLE_TOKEN to FlowAddress("0x631e88ae7f1d7c20"), NFT_STOREFRONT to FlowAddress("0x94b06cfca1d8a476"), - EVM to FlowAddress("0x8c5303eaa26202d6") + EVM to FlowAddress("0x8c5303eaa26202d6"), + HYBRID_CUSTODY to FlowAddress("0x294e44e1ec6993c6") ), ChainId.Mainnet to mutableMapOf( FUNGIBLE_TOKEN to FlowAddress("0xf233dcee88fe0abe"), @@ -96,7 +98,8 @@ class AddressRegistry { NON_FUNGIBLE_TOKEN to FlowAddress("0x1d7e57aa55817448"), NFT_STOREFRONT to FlowAddress("0x4eb8a10cb9f87357"), TOKEN_FORWARDING to FlowAddress("0xe544175ee0461c4b"), - EVM to FlowAddress("0xe467b9dd11fa00df") + EVM to FlowAddress("0xe467b9dd11fa00df"), + HYBRID_CUSTODY to FlowAddress("0xd8a7e05a7ac670c0") ), ).forEach { chain -> chain.value.forEach { diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/CadenceTarget.kt b/flow/src/commonMain/kotlin/org/onflow/flow/CadenceTarget.kt new file mode 100644 index 0000000..9a99b77 --- /dev/null +++ b/flow/src/commonMain/kotlin/org/onflow/flow/CadenceTarget.kt @@ -0,0 +1,41 @@ +package org.onflow.flow + +import io.ktor.util.decodeBase64Bytes +import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.models.Signer +import org.onflow.flow.models.Transaction + +enum class CadenceTargetType { + Query, + Transaction +} + +data class CadenceTarget( + val cadenceBase64: String, + val type: CadenceTargetType, + val arguments: List = emptyList() +) + +suspend fun FlowApi.query(target: CadenceTarget): Cadence.Value { + val script = target.cadenceBase64.decodeBase64Bytes().decodeToString() + return executeScript(script = script, arguments = target.arguments) +} + +suspend fun FlowApi.sendTransaction( + target: CadenceTarget, + signers: List, + chainId: ChainIdProvider = this.chainId, + addressRegistry: AddressRegistry = AddressRegistry(), + builder: TransactionDSLBuilder.() -> Unit +): Transaction { + val script = target.cadenceBase64.decodeBase64Bytes().decodeToString() + val combinedBuilder: TransactionDSLBuilder.() -> Unit = { + cadence { script } + arguments { target.arguments } + builder() + } + + val unsignedTx = buildTransaction(chainId, addressRegistry, combinedBuilder) + val signedTx = signTransaction(unsignedTx, signers) + return sendTransaction(signedTx) +} diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/FlowApi.kt b/flow/src/commonMain/kotlin/org/onflow/flow/FlowApi.kt index b3d0b54..2e69136 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/FlowApi.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/FlowApi.kt @@ -91,4 +91,4 @@ class FlowApi(val chainId: ChainIdProvider) { suspend fun waitForSeal(transactionId: String): TransactionResult { return transactionsApi.waitForSeal(transactionId) } -} \ No newline at end of file +} diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/TransactionDSL.kt b/flow/src/commonMain/kotlin/org/onflow/flow/TransactionDSL.kt new file mode 100644 index 0000000..3be0d6b --- /dev/null +++ b/flow/src/commonMain/kotlin/org/onflow/flow/TransactionDSL.kt @@ -0,0 +1,162 @@ +package org.onflow.flow + +import com.ionspin.kotlin.bignum.integer.BigInteger +import com.ionspin.kotlin.bignum.integer.toBigInteger +import org.onflow.flow.apis.AccountsApi +import org.onflow.flow.apis.BlocksApi +import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.infrastructure.removeHexPrefix +import org.onflow.flow.models.FlowAddress +import org.onflow.flow.models.ProposalKey +import org.onflow.flow.models.Signer +import org.onflow.flow.models.Transaction + +/** + * DSL entry point for building Flow transactions, mirroring the flow-swift builder style. + * Handles address resolution, optional reference block lookup, and proposer key hydration. + */ +class TransactionDSLBuilder internal constructor( + private val chainId: ChainIdProvider, + private val addressRegistry: AddressRegistry +) { + internal var script: String? = null + internal val argumentsList: MutableList = mutableListOf() + internal var referenceBlockId: String? = null + internal var gasLimit: BigInteger = 9999.toBigInteger() + internal var payer: String? = null + internal var proposalKey: ProposalKey? = null + internal val authorizers: MutableList = mutableListOf() + + init { + if (chainId is ChainId) { + addressRegistry.defaultChainId = chainId + } + } + + fun cadence(code: () -> String) { + script = code() + } + + fun cadence(code: String) = cadence { code } + + fun script(code: String) = cadence { code } + + fun arguments(arguments: () -> List) { + argumentsList.clear() + argumentsList.addAll(arguments()) + } + + fun argument(argument: Cadence.Value) { + argumentsList.add(argument) + } + + fun args(vararg arguments: Cadence.Value) { + this.argumentsList.addAll(arguments) + } + + fun gasLimit(limit: Int) { + gasLimit = limit.toBigInteger() + } + + fun gasLimit(limit: BigInteger) { + gasLimit = limit + } + + fun refBlock(id: String?) { + referenceBlockId = id + } + + fun payer(address: String) { + payer = address.removeHexPrefix() + } + + fun payer(address: FlowAddress) { + payer = address.base16Value + } + + fun proposer(address: String, keyIndex: Int = 0, sequenceNumber: BigInteger = (-1).toBigInteger()) { + proposalKey = ProposalKey(address.removeHexPrefix(), keyIndex, sequenceNumber) + } + + fun proposer(address: String) = proposer(address, keyIndex = -1, sequenceNumber = (-1).toBigInteger()) + + fun proposer(address: FlowAddress, keyIndex: Int = 0, sequenceNumber: BigInteger = (-1).toBigInteger()) = + proposer(address.base16Value, keyIndex, sequenceNumber) + + fun authorizers(vararg addresses: String) { + this.authorizers.addAll(addresses.map { it.removeHexPrefix() }) + } + + fun authorizers(addresses: () -> List) { + this.authorizers.addAll(addresses().map { it.removeHexPrefix() }) + } + + fun authorizers(addresses: List) { + this.authorizers.addAll(addresses.map { it.base16Value }) + } +} + +suspend fun FlowApi.buildTransaction( + chainId: ChainIdProvider = this.chainId, + addressRegistry: AddressRegistry = AddressRegistry(), + builder: TransactionDSLBuilder.() -> Unit +): Transaction { + val blocksApi = BlocksApi(chainId.baseUrl) + val accountsApi = AccountsApi(chainId.baseUrl) + val dsl = TransactionDSLBuilder(chainId, addressRegistry).apply(builder) + + val rawScript = dsl.script ?: throw IllegalStateException("Cadence script must be provided") + val processedScript = addressRegistry.processScript(rawScript, chainId) + + val resolvedProposalKey = dsl.proposalKey?.let { proposalKey -> + val account = accountsApi.getAccount(proposalKey.address.removeHexPrefix()) + val accountKey = if (proposalKey.keyIndex >= 0) { + account.keys?.firstOrNull { it.index.toInt() == proposalKey.keyIndex } + } else { + account.keys?.firstOrNull() + } ?: throw IllegalArgumentException("No key found for ${proposalKey.address} at index ${proposalKey.keyIndex}") + + val sequence = if (proposalKey.sequenceNumber < 0.toBigInteger()) { + accountKey.sequenceNumber.toBigInteger() + } else { + proposalKey.sequenceNumber + } + + proposalKey.copy( + keyIndex = accountKey.index.toInt(), + sequenceNumber = sequence + ) + } ?: throw IllegalStateException("Proposal key must be provided") + + val resolvedRefBlockId = dsl.referenceBlockId ?: blocksApi.getBlock().header.id + val payerAddress = dsl.payer ?: resolvedProposalKey.address + val authorizerList = if (dsl.authorizers.isNotEmpty()) { + dsl.authorizers.toList() + } else { + listOf(resolvedProposalKey.address) + } + + return Transaction( + script = processedScript, + arguments = dsl.argumentsList.toList(), + referenceBlockId = resolvedRefBlockId, + gasLimit = dsl.gasLimit, + payer = payerAddress, + proposalKey = resolvedProposalKey, + authorizers = authorizerList + ) +} + +suspend fun FlowApi.signTransaction(unsignedTransaction: Transaction, signers: List): Transaction = + unsignedTransaction.sign(signers) + +suspend fun FlowApi.sendTransaction( + signers: List, + chainId: ChainIdProvider = this.chainId, + addressRegistry: AddressRegistry = AddressRegistry(), + builder: TransactionDSLBuilder.() -> Unit +): Transaction { + val unsignedTransaction = buildTransaction(chainId, addressRegistry, builder) + val signedTransaction = signTransaction(unsignedTransaction, signers) + return sendTransaction(signedTransaction) +} diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/TransactionResultExtensions.kt b/flow/src/commonMain/kotlin/org/onflow/flow/TransactionResultExtensions.kt new file mode 100644 index 0000000..033ecb3 --- /dev/null +++ b/flow/src/commonMain/kotlin/org/onflow/flow/TransactionResultExtensions.kt @@ -0,0 +1,47 @@ +package org.onflow.flow + +import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.infrastructure.getField +import org.onflow.flow.models.Event +import org.onflow.flow.models.TransactionResult +import org.onflow.flow.models.TransactionStatus + +private const val ACCOUNT_CREATED_EVENT_TYPE = "flow.AccountCreated" +private const val ACCOUNT_CREATED_FIELD_NAME = "address" + +/** + * Find the first event of a given type. + */ +fun TransactionResult.findEvent(type: String): Event? = + events.firstOrNull { it.type == type } + +/** + * Decode a field from an event payload (Cadence.EventValue) by name. + */ +inline fun Event.getField(name: String): T? { + val eventValue = payload as? Cadence.Value.EventValue ?: return null + return eventValue.value.getField(name) +} + +/** + * Return the created account address (hex string) from a transaction result if present. + */ +fun TransactionResult.getCreatedAddress(): String? = + findEvent(ACCOUNT_CREATED_EVENT_TYPE)?.getField(ACCOUNT_CREATED_FIELD_NAME) + +/** + * Convenience: fetch transaction result and return created address if present. + */ +suspend fun FlowApi.getCreatedAccountAddress(txId: String): String? = + getTransactionResult(txId).getCreatedAddress() + +/** + * Wait until transaction is at least EXECUTED, then try to read created account address. + * Uses waitForSeal to guarantee a sealed result before reading events. + */ +suspend fun FlowApi.waitForCreatedAccountAddress( + txId: String +): String? { + val sealed = waitForSeal(txId) + return sealed.getCreatedAddress() +} diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/apis/TransactionsApi.kt b/flow/src/commonMain/kotlin/org/onflow/flow/apis/TransactionsApi.kt index c269f10..d05b580 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/apis/TransactionsApi.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/apis/TransactionsApi.kt @@ -23,12 +23,13 @@ internal class TransactionsApi(val baseUrl: String) : ApiBase() { expand: Set? = null, select: Set? = null ): TransactionResult { + val normalizedId = transactionId.removePrefix("0x") val queryParams = mutableMapOf>() expand?.apply { queryParams["expand"] = toMultiValue(this, "csv") } select?.apply { queryParams["select"] = toMultiValue(this, "csv") } - return client.get("$baseUrl/transaction_results/$transactionId") { + return client.get("$baseUrl/transaction_results/$normalizedId") { queryParams.forEach { queryParam -> queryParam.value.forEach { value -> parameter(queryParam.key, value) @@ -42,12 +43,13 @@ internal class TransactionsApi(val baseUrl: String) : ApiBase() { expand: Set? = null, select: Set? = null ): Transaction { + val normalizedId = id.removePrefix("0x") val queryParams = mutableMapOf>() expand?.apply { queryParams["expand"] = toMultiValue(this, "csv") } select?.apply { queryParams["select"] = toMultiValue(this, "csv") } - val result = client.get("$baseUrl/transactions/$id") { + val result = client.get("$baseUrl/transactions/$normalizedId") { queryParams.forEach { queryParam -> queryParam.value.forEach { value -> parameter(queryParam.key, value) @@ -80,11 +82,11 @@ internal class TransactionsApi(val baseUrl: String) : ApiBase() { } internal suspend fun getTransactionResult(transactionId: String): TransactionResult { - return requestTransactionResultById(transactionId) + return requestTransactionResultById(transactionId.removePrefix("0x")) } internal suspend fun getTransaction(transactionId: String): Transaction { - return requestTransactionById(transactionId) + return requestTransactionById(transactionId.removePrefix("0x")) } internal suspend fun sendTransaction(request: Transaction): Transaction { @@ -92,13 +94,14 @@ internal class TransactionsApi(val baseUrl: String) : ApiBase() { } internal suspend fun waitForSeal(transactionId: String): TransactionResult { + val normalizedId = transactionId.removePrefix("0x") var attempts = 0 val maxAttempts = 60 // Increased to 60 attempts (up to 2 minutes) var lastError: Exception? = null while (attempts < maxAttempts) { try { - val result = getTransactionResult(transactionId) + val result = getTransactionResult(normalizedId) when (result.status ?: TransactionStatus.EMPTY) { TransactionStatus.EXECUTED -> { // Transaction is executed, return result @@ -143,10 +146,4 @@ internal class TransactionsApi(val baseUrl: String) : ApiBase() { } throw RuntimeException(timeoutMessage) } - - private suspend fun resolveKeyIndex(address: FlowAddress, accountsApi: AccountsApi): Int { - val account = accountsApi.getAccount(address.base16Value) - return account.keys?.firstOrNull()?.index?.toInt() - ?: throw IllegalStateException("No keys found for address $address") - } } diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/cadence/CadenceQueries.kt b/flow/src/commonMain/kotlin/org/onflow/flow/cadence/CadenceQueries.kt new file mode 100644 index 0000000..b6460ea --- /dev/null +++ b/flow/src/commonMain/kotlin/org/onflow/flow/cadence/CadenceQueries.kt @@ -0,0 +1,83 @@ +package org.onflow.flow.cadence + +import kotlinx.serialization.Serializable +import org.onflow.flow.FlowApi +import org.onflow.flow.ChainId +import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.infrastructure.decode +import org.onflow.flow.infrastructure.scripts.CadenceScriptLoader +import org.onflow.flow.models.FlowAddress + +@Serializable +data class ChildAccountThumbnail( + val url: String? = null +) + +@Serializable +data class ChildAccountMetadata( + val name: String? = null, + val description: String? = null, + val address: String? = null, + val thumbnail: ChildAccountThumbnail? = null +) + +@Serializable +data class StakingNode( + val id: Int, + val nodeID: String, + val tokensCommitted: Double, + val tokensStaked: Double, + val tokensUnstaking: Double, + val tokensRewarded: Double, + val tokensUnstaked: Double, + val tokensRequestedToUnstake: Double +) { + val stakingCount: Double + get() = tokensCommitted + tokensStaked +} + +suspend fun FlowApi.getChildAddresses(parent: FlowAddress): List { + val script = CadenceScriptLoader(requireChainId()).load("get_child_addresses", "common/child") + val result = executeScript( + script = script, + arguments = listOf(Cadence.address(parent.base16Value)) + ) + return result.decode() +} + +suspend fun FlowApi.getChildAccountMetadata(parent: FlowAddress): Map { + val script = CadenceScriptLoader(requireChainId()).load("get_child_account_meta", "common/child") + val result = executeScript( + script = script, + arguments = listOf(Cadence.address(parent.base16Value)) + ) + + val decodedMapWithNulls = result.decode>() + return decodedMapWithNulls + .filterValues { it != null } + .map { (key, value) -> + key to value!!.copy(address = value.address ?: key) + } + .toMap() +} + +suspend fun FlowApi.getTokenBalances(address: FlowAddress): Map { + val script = CadenceScriptLoader(requireChainId()).load("get_token_balance_storage", "common/token") + val result = executeScript( + script = script, + arguments = listOf(Cadence.address(address.base16Value)) + ) + return result.decode() +} + +suspend fun FlowApi.getDelegatorInfo(address: FlowAddress): List? { + val script = CadenceScriptLoader(requireChainId()).load("get_delegator_info", "common/staking") + val result = executeScript( + script = script, + arguments = listOf(Cadence.address(address.base16Value)) + ) + return result.decode() +} + +private fun FlowApi.requireChainId(): ChainId = + (chainId as? ChainId) ?: throw IllegalArgumentException("Cadence script loading requires ChainId") diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/evm/EVMManager.kt b/flow/src/commonMain/kotlin/org/onflow/flow/evm/EVMManager.kt index 54d943f..3b3c87e 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/evm/EVMManager.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/evm/EVMManager.kt @@ -6,12 +6,13 @@ import org.onflow.flow.apis.AccountsApi import org.onflow.flow.apis.BlocksApi import org.onflow.flow.apis.TransactionsApi import org.onflow.flow.apis.ScriptsApi +import org.onflow.flow.FlowApi +import org.onflow.flow.sendTransaction +import org.onflow.flow.cadence.ChildAccountMetadata import org.onflow.flow.models.FlowAddress import org.onflow.flow.models.Signer -import kotlinx.serialization.Serializable import org.onflow.flow.infrastructure.Cadence import org.onflow.flow.infrastructure.scripts.CadenceScriptLoader -import org.onflow.flow.models.TransactionBuilder class EVMManager(chainId: ChainId) { private val baseUrl = chainId.baseUrl @@ -38,6 +39,30 @@ class EVMManager(chainId: ChainId) { signers: List ): String = impl.createCOAAccount(proposer, payer, amount, signers) + /** + * Execute an EVM transaction via Cadence `evm_run` + */ + suspend fun runEVMTransaction( + proposer: FlowAddress, + payer: FlowAddress, + rlpEncodedTransaction: ByteArray, + coinbaseAddress: String, + signers: List + ): String = impl.runEVMTransaction(proposer, payer, rlpEncodedTransaction, coinbaseAddress, signers) + + /** + * Call an EVM contract from a COA via `call_contract` + */ + suspend fun callEVMContract( + proposer: FlowAddress, + payer: FlowAddress, + toEVMAddressHex: String, + amount: Double, + data: ByteArray, + gasLimit: ULong, + signers: List + ): String = impl.callEVMContract(proposer, payer, toEVMAddressHex, amount, data, gasLimit, signers) + /** * Gets the EVM address associated with a Flow address * @param flowAddress The Flow address to look up @@ -52,19 +77,6 @@ class EVMManager(chainId: ChainId) { */ suspend fun getChildAccountMetadata(flowAddress: FlowAddress): Map = impl.getChildAccountMetadata(flowAddress) - @Serializable - data class ChildAccountMetadata( - val name: String? = null, - val description: String? = null, - val address: String? = null, - val thumbnail: Thumbnail? = null - ) - - @Serializable - data class Thumbnail( - val url: String? = null - ) - private class EVMManagerImpl( private val accountsApi: AccountsApi, private val blocksApi: BlocksApi, @@ -74,6 +86,10 @@ class EVMManager(chainId: ChainId) { ) { private val scriptLoader = CadenceScriptLoader(chainId) + private val flowApi = FlowApi(chainId) + + private fun ByteArray.toCadenceUInt8Array(): Cadence.Value = + Cadence.array(this.map { Cadence.uint8(it.toUByte()) }) suspend fun createCOAAccount( proposer: FlowAddress, @@ -82,35 +98,77 @@ class EVMManager(chainId: ChainId) { signers: List ): String { val script = scriptLoader.load("create_coa", "common/evm") - val latestBlock = blocksApi.getBlock() - val proposerAccount = accountsApi.getAccount(proposer.base16Value) - val proposerKey = proposerAccount.keys?.firstOrNull() - ?: throw IllegalArgumentException("Proposer has no keys") - - // Fill in keyIndex dynamically if not set - signers.forEach { signer -> - if (signer.keyIndex == -1) { - val account = accountsApi.getAccount(signer.address) - signer.keyIndex = account.keys?.firstOrNull()?.index?.toInt() - ?: throw IllegalStateException("No key found for ${signer.address}") - } + val resultTx = flowApi.sendTransaction( + signers = signers, + chainId = chainId + ) { + cadence { script } + arguments { listOf(Cadence.ufix64(amount)) } + proposer(proposer.base16Value) + payer(payer.base16Value) + authorizers(proposer.base16Value) + } + + return resultTx.id ?: throw IllegalStateException("Transaction did not return an ID") + } + + suspend fun runEVMTransaction( + proposer: FlowAddress, + payer: FlowAddress, + rlpEncodedTransaction: ByteArray, + coinbaseAddress: String, + signers: List + ): String { + val script = scriptLoader.load("evm_run", "common/evm") + val txArgs = listOf( + rlpEncodedTransaction.toCadenceUInt8Array(), + Cadence.string(coinbaseAddress) + ) + + val resultTx = flowApi.sendTransaction( + signers = signers, + chainId = chainId + ) { + cadence { script } + arguments { txArgs } + proposer(proposer.base16Value) + payer(payer.base16Value) + authorizers(proposer.base16Value) + } + + return resultTx.id ?: throw IllegalStateException("Transaction did not return an ID") + } + + suspend fun callEVMContract( + proposer: FlowAddress, + payer: FlowAddress, + toEVMAddressHex: String, + amount: Double, + data: ByteArray, + gasLimit: ULong, + signers: List + ): String { + val script = scriptLoader.load("call_contract", "common/evm") + val txArgs = listOf( + Cadence.string(toEVMAddressHex), + Cadence.ufix64(amount), + data.toCadenceUInt8Array(), + Cadence.uint64(gasLimit) + ) + + val resultTx = flowApi.sendTransaction( + signers = signers, + chainId = chainId + ) { + cadence { script } + arguments { txArgs } + proposer(proposer.base16Value) + payer(payer.base16Value) + authorizers(proposer.base16Value) + gasLimit(gasLimit.toLong().toBigInteger()) } - // Create and sign transaction in one step - val signedTransaction = TransactionBuilder(script, listOf(Cadence.ufix64(amount))) - .withReferenceBlockId(latestBlock.header.id) - .withPayer(payer.base16Value) - .withProposalKey( - address = proposer.base16Value, - keyIndex = proposerKey.index.toInt(), - sequenceNumber = proposerKey.sequenceNumber.toBigInteger() - ) - .withAuthorizers(listOf(proposer.base16Value)) - .withSigners(signers) - .buildAndSign() - - val result = transactionsApi.sendTransaction(signedTransaction) - return result.id ?: throw IllegalStateException("Transaction did not return an ID") + return resultTx.id ?: throw IllegalStateException("Transaction did not return an ID") } suspend fun getEVMAddress(flowAddress: FlowAddress): String { @@ -123,7 +181,7 @@ class EVMManager(chainId: ChainId) { } suspend fun getChildAccountMetadata(flowAddress: FlowAddress): Map { - val script = scriptLoader.load("get_child_account_meta", "common/evm") + val script = scriptLoader.load("get_child_account_meta", "common/child") val result = scriptsApi.executeScript( script = script, arguments = listOf(Cadence.address(flowAddress.base16Value)) diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/models/Address.kt b/flow/src/commonMain/kotlin/org/onflow/flow/models/Address.kt index ed6ddea..44acd44 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/models/Address.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/models/Address.kt @@ -18,6 +18,9 @@ data class FlowAddress(val bytes: ByteArray) { val base16Value: String get() = bytes.toHexString() + // Alias for base16Value for more idiomatic naming + val hex: String get() = base16Value + val formatted: String = "0x$base16Value" override fun hashCode(): Int { diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/models/Signer.kt b/flow/src/commonMain/kotlin/org/onflow/flow/models/Signer.kt index 3c21685..856e2c3 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/models/Signer.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/models/Signer.kt @@ -3,14 +3,15 @@ package org.onflow.flow.models interface Signer { var address: String var keyIndex: Int - suspend fun sign(transaction: Transaction? = null, bytes: ByteArray): ByteArray - suspend fun sign(bytes: ByteArray): ByteArray - suspend fun signWithDomain(bytes: ByteArray, domain: ByteArray): ByteArray = sign(domain + bytes) + suspend fun sign(bytes: ByteArray, transaction: Transaction? = null): ByteArray + suspend fun signWithDomain(bytes: ByteArray, domain: ByteArray, transaction: Transaction? = null): ByteArray = + sign(domain + bytes, transaction) suspend fun signAsUser(bytes: ByteArray): ByteArray = signWithDomain(bytes, DomainTag.User.bytes) - suspend fun signAsTransaction(bytes: ByteArray): ByteArray = signWithDomain(bytes, DomainTag.Transaction.bytes) + suspend fun signAsTransaction(bytes: ByteArray, transaction: Transaction? = null): ByteArray = + signWithDomain(bytes, DomainTag.Transaction.bytes, transaction) } interface Hasher { fun hash(bytes: ByteArray): ByteArray fun hashAsHexString(bytes: ByteArray): String = hash(bytes).toHexString() -} \ No newline at end of file +} diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/websocket/FlowWebSocketClient.kt b/flow/src/commonMain/kotlin/org/onflow/flow/websocket/FlowWebSocketClient.kt index 4f6f81c..6d039d4 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/websocket/FlowWebSocketClient.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/websocket/FlowWebSocketClient.kt @@ -12,9 +12,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.channels.* import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive class FlowWebSocketClient( diff --git a/flow/src/commonMain/resources/scripts/common/addresses.json b/flow/src/commonMain/resources/scripts/common/addresses.json index 3acde6d..ac2e180 100644 --- a/flow/src/commonMain/resources/scripts/common/addresses.json +++ b/flow/src/commonMain/resources/scripts/common/addresses.json @@ -38,4 +38,4 @@ "0xFlowIDTableStaking": "0x8624b52f9ddcd04a", "0xLockedTokens": "0x8d0e87b65159ae63" } -} \ No newline at end of file +} diff --git a/flow/src/commonMain/resources/scripts/common/child/get_child_account_meta.cdc b/flow/src/commonMain/resources/scripts/common/child/get_child_account_meta.cdc new file mode 100644 index 0000000..0c22ded --- /dev/null +++ b/flow/src/commonMain/resources/scripts/common/child/get_child_account_meta.cdc @@ -0,0 +1,18 @@ +import HybridCustody from 0xHYBRIDCUSTODY +import MetadataViews from 0xNONFUNGIBLETOKEN + +access(all) fun main(parent: Address): {Address: AnyStruct} { + let acct = getAuthAccount(parent) + let m = acct.storage.borrow<&HybridCustody.Manager>(from: HybridCustody.ManagerStoragePath) + + if m == nil { + return {} + } else { + var data: {Address: AnyStruct} = {} + for address in m?.getChildAddresses()! { + let c = m?.getChildAccountDisplay(address: address) + data.insert(key: address, c) + } + return data + } +} diff --git a/flow/src/commonMain/resources/scripts/common/child/get_child_addresses.cdc b/flow/src/commonMain/resources/scripts/common/child/get_child_addresses.cdc new file mode 100644 index 0000000..a6af1a6 --- /dev/null +++ b/flow/src/commonMain/resources/scripts/common/child/get_child_addresses.cdc @@ -0,0 +1,9 @@ +import HybridCustody from 0xHYBRIDCUSTODY + +access(all) fun main(parent: Address): [Address] { + let acct = getAuthAccount(parent) + if let manager = acct.storage.borrow<&HybridCustody.Manager>(from: HybridCustody.ManagerStoragePath) { + return manager.getChildAddresses() + } + return [] +} diff --git a/flow/src/commonMain/resources/scripts/common/evm/call_contract.cdc b/flow/src/commonMain/resources/scripts/common/evm/call_contract.cdc new file mode 100644 index 0000000..d22581f --- /dev/null +++ b/flow/src/commonMain/resources/scripts/common/evm/call_contract.cdc @@ -0,0 +1,40 @@ +import FungibleToken from 0xFUNGIBLETOKEN +import FlowToken from 0xFLOWTOKEN +import EVM from 0xEVM + +/// Transfers $FLOW from the signer's account Cadence Flow balance to the recipient's hex-encoded EVM address. +/// Note that a COA must have a $FLOW balance in EVM before transferring value to another EVM address. +/// +transaction(toEVMAddressHex: String, amount: UFix64, data: [UInt8], gasLimit: UInt64) { + + let coa: auth(EVM.Withdraw, EVM.Call) &EVM.CadenceOwnedAccount + let recipientEVMAddress: EVM.EVMAddress + + prepare(signer: auth(BorrowValue, SaveValue) &Account) { + if signer.storage.type(at: /storage/evm) == nil { + signer.storage.save(<-EVM.createCadenceOwnedAccount(), to: /storage/evm) + } + self.coa = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow reference to the signer's bridged account") + + self.recipientEVMAddress = EVM.addressFromString(toEVMAddressHex) + } + + execute { + if self.recipientEVMAddress.bytes == self.coa.address().bytes { + return + } + let valueBalance = EVM.Balance(attoflow: 0) + valueBalance.setFLOW(flow: amount) + let txResult = self.coa.call( + to: self.recipientEVMAddress, + data: data, + gasLimit: gasLimit, + value: valueBalance + ) + assert( + txResult.status == EVM.Status.failed || txResult.status == EVM.Status.successful, + message: "evm_error=".concat(txResult.errorMessage).concat("\n") + ) + } +} diff --git a/flow/src/commonMain/resources/scripts/common/evm/evm_run.cdc b/flow/src/commonMain/resources/scripts/common/evm/evm_run.cdc new file mode 100644 index 0000000..1218587 --- /dev/null +++ b/flow/src/commonMain/resources/scripts/common/evm/evm_run.cdc @@ -0,0 +1,19 @@ +import FungibleToken from 0xFUNGIBLETOKEN +import FlowToken from 0xFLOWTOKEN +import EVM from 0xEVM + +transaction(rlpEncodedTransaction: [UInt8], coinbaseAddr: String) { + + prepare(signer: auth(Storage, EVM.Withdraw) &Account) { + let coinbase = EVM.addressFromString(coinbaseAddr) + + let runResult = EVM.run(tx: rlpEncodedTransaction, coinbase: coinbase) + assert( + runResult.status == EVM.Status.successful, + message: "evm tx was not executed successfully." + ) + } + + execute { + } +} diff --git a/flow/src/commonMain/resources/scripts/common/staking/get_delegator_info.cdc b/flow/src/commonMain/resources/scripts/common/staking/get_delegator_info.cdc new file mode 100644 index 0000000..8c2b2e4 --- /dev/null +++ b/flow/src/commonMain/resources/scripts/common/staking/get_delegator_info.cdc @@ -0,0 +1,14 @@ +import FlowStakingCollection from 0xFLOWTABLESTAKING +import FlowIDTableStaking from 0xFLOWTABLESTAKING +import LockedTokens from 0xLOCKEDTOKENS + +access(all) fun main(address: Address): [FlowIDTableStaking.DelegatorInfo]? { + var res: [FlowIDTableStaking.DelegatorInfo]? = nil + + let inited = FlowStakingCollection.doesAccountHaveStakingCollection(address: address) + + if inited { + res = FlowStakingCollection.getAllDelegatorInfo(address: address) + } + return res +} diff --git a/flow/src/commonMain/resources/scripts/common/token/get_token_balance_storage.cdc b/flow/src/commonMain/resources/scripts/common/token/get_token_balance_storage.cdc new file mode 100644 index 0000000..3be0373 --- /dev/null +++ b/flow/src/commonMain/resources/scripts/common/token/get_token_balance_storage.cdc @@ -0,0 +1,35 @@ +import FungibleToken from 0xFUNGIBLETOKEN + +/// Queries for FT.Vault balance of all FT.Vaults in the specified account. +/// +access(all) fun main(address: Address): {String: UFix64} { + // Get the account + let account = getAuthAccount(address) + // Init for return value + let balances: {String: UFix64} = {} + // Track seen Types in array + let seen: [String] = [] + // Assign the type we'll need + let vaultType: Type = Type<@{FungibleToken.Vault}>() + // Iterate over all stored items & get the path if the type is what we're looking for + account.storage.forEachStored(fun (path: StoragePath, type: Type): Bool { + if !type.isRecovered && (type.isInstance(vaultType) || type.isSubtype(of: vaultType)) { + // Get a reference to the resource & its balance + let vaultRef = account.storage.borrow<&{FungibleToken.Balance}>(from: path)! + // Insert a new values if it's the first time we've seen the type + if !seen.contains(type.identifier) { + balances.insert(key: type.identifier, vaultRef.balance) + } else { + // Otherwise just update the balance of the vault (unlikely we'll see the same type twice in + // the same account, but we want to cover the case) + balances[type.identifier] = balances[type.identifier]! + vaultRef.balance + } + } + return true + }) + + // Add available Flow Token Balance + balances.insert(key: "availableFlowToken", account.availableBalance) + + return balances +} diff --git a/flow/src/commonTest/kotlin/org/onflow/flow/TransactionResultExtensionsTest.kt b/flow/src/commonTest/kotlin/org/onflow/flow/TransactionResultExtensionsTest.kt new file mode 100644 index 0000000..3b7bccc --- /dev/null +++ b/flow/src/commonTest/kotlin/org/onflow/flow/TransactionResultExtensionsTest.kt @@ -0,0 +1,72 @@ +package org.onflow.flow + +import kotlinx.coroutines.runBlocking +import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.models.Event +import org.onflow.flow.models.TransactionResult +import org.onflow.flow.models.TransactionStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TransactionResultExtensionsTest { + + @Test + fun `getCreatedAddress should return address when event present`() { + val payload = Cadence.Value.EventValue( + Cadence.CompositeValue( + id = "flow.AccountCreated", + fields = listOf( + Cadence.CompositeAttribute( + name = "address", + value = Cadence.address("0x1234567890abcdef") + ) + ) + ) + ) + + val event = Event( + type = "flow.AccountCreated", + transactionId = "tx", + transactionIndex = "0", + eventIndex = "0", + payload = payload + ) + + val txResult = TransactionResult( + blockId = "block", + status = TransactionStatus.SEALED, + statusCode = 0, + errorMessage = "", + computationUsed = "0", + events = listOf(event) + ) + + val test = txResult.findEvent("flow.AccountCreated") + val test2 = test?.getField("address") + assertEquals("0x1234567890abcdef", txResult.getCreatedAddress()) + } + + @Test + fun `getCreatedAddress should return null when event missing`() { + val txResult = TransactionResult( + blockId = "block", + status = TransactionStatus.EXECUTED, + statusCode = 0, + errorMessage = "", + computationUsed = "0", + events = emptyList() + ) + + assertNull(txResult.getCreatedAddress()) + } + + @Test + fun `waitForCreatedAccountAddress returns created address after seal`() = runBlocking { + val txId = "0xd7320b52ce88d37855086facadaf0214a1c184078527a109ab9d002a4296fb7b" + val api = FlowApi(ChainId.Mainnet) + val addr = api.waitForCreatedAccountAddress(txId) + // monkey-patch via extension call using sealed result + assertEquals("0x55a8d27a989c4706", addr) + } +}