From de176e958dc12f381d4ad71fdac5422b013622ec Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Wed, 27 Dec 2023 07:39:07 -0500 Subject: [PATCH] adding back algorand stuff --- examples/src/algoTokenBridge.ts | 175 +++++ platforms/algorand/README.md | 5 + platforms/algorand/eslintrc.json | 20 + platforms/algorand/package.json | 48 ++ .../algorand/protocols/core/package.json | 48 ++ platforms/algorand/protocols/core/src/core.ts | 125 ++++ .../algorand/protocols/core/src/index.ts | 15 + .../algorand/protocols/core/tsconfig.cjs.json | 8 + .../algorand/protocols/core/tsconfig.esm.json | 8 + .../algorand/protocols/core/typedoc.json | 4 + .../protocols/tokenBridge/package.json | 48 ++ .../protocols/tokenBridge/src/BigVarint.ts | 57 ++ .../protocols/tokenBridge/src/TmplSig.ts | 59 ++ .../protocols/tokenBridge/src/apps.ts | 132 ++++ .../protocols/tokenBridge/src/assets.ts | 197 ++++++ .../protocols/tokenBridge/src/constants.ts | 30 + .../protocols/tokenBridge/src/index.ts | 24 + .../protocols/tokenBridge/src/tokenBridge.ts | 285 ++++++++ .../protocols/tokenBridge/src/transfers.ts | 420 ++++++++++++ .../protocols/tokenBridge/src/types.ts | 66 ++ .../protocols/tokenBridge/src/utilities.ts | 55 ++ .../algorand/protocols/tokenBridge/src/vaa.ts | 623 ++++++++++++++++++ .../protocols/tokenBridge/tsconfig.cjs.json | 8 + .../protocols/tokenBridge/tsconfig.esm.json | 8 + .../protocols/tokenBridge/typedoc.json | 4 + platforms/algorand/src/address.ts | 76 +++ platforms/algorand/src/chain.ts | 7 + platforms/algorand/src/constants.ts | 17 + platforms/algorand/src/index.ts | 8 + platforms/algorand/src/platform.ts | 199 ++++++ platforms/algorand/src/testing/index.ts | 1 + platforms/algorand/src/testing/signer.ts | 75 +++ platforms/algorand/src/types.ts | 19 + platforms/algorand/src/unsignedTransaction.ts | 14 + platforms/algorand/tsconfig.cjs.json | 8 + platforms/algorand/tsconfig.esm.json | 8 + platforms/algorand/typedoc.json | 4 + 37 files changed, 2908 insertions(+) create mode 100644 examples/src/algoTokenBridge.ts create mode 100644 platforms/algorand/README.md create mode 100644 platforms/algorand/eslintrc.json create mode 100644 platforms/algorand/package.json create mode 100644 platforms/algorand/protocols/core/package.json create mode 100644 platforms/algorand/protocols/core/src/core.ts create mode 100644 platforms/algorand/protocols/core/src/index.ts create mode 100644 platforms/algorand/protocols/core/tsconfig.cjs.json create mode 100644 platforms/algorand/protocols/core/tsconfig.esm.json create mode 100644 platforms/algorand/protocols/core/typedoc.json create mode 100644 platforms/algorand/protocols/tokenBridge/package.json create mode 100644 platforms/algorand/protocols/tokenBridge/src/BigVarint.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/TmplSig.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/apps.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/assets.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/constants.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/index.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/tokenBridge.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/transfers.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/types.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/utilities.ts create mode 100644 platforms/algorand/protocols/tokenBridge/src/vaa.ts create mode 100644 platforms/algorand/protocols/tokenBridge/tsconfig.cjs.json create mode 100644 platforms/algorand/protocols/tokenBridge/tsconfig.esm.json create mode 100644 platforms/algorand/protocols/tokenBridge/typedoc.json create mode 100644 platforms/algorand/src/address.ts create mode 100644 platforms/algorand/src/chain.ts create mode 100644 platforms/algorand/src/constants.ts create mode 100644 platforms/algorand/src/index.ts create mode 100644 platforms/algorand/src/platform.ts create mode 100644 platforms/algorand/src/testing/index.ts create mode 100644 platforms/algorand/src/testing/signer.ts create mode 100644 platforms/algorand/src/types.ts create mode 100644 platforms/algorand/src/unsignedTransaction.ts create mode 100644 platforms/algorand/tsconfig.cjs.json create mode 100644 platforms/algorand/tsconfig.esm.json create mode 100644 platforms/algorand/typedoc.json diff --git a/examples/src/algoTokenBridge.ts b/examples/src/algoTokenBridge.ts new file mode 100644 index 000000000..bda25afd3 --- /dev/null +++ b/examples/src/algoTokenBridge.ts @@ -0,0 +1,175 @@ +import { + Chain, + Network, + Platform, + TokenId, + TokenTransfer, + Wormhole, + normalizeAmount, +} from "@wormhole-foundation/connect-sdk"; +import { TransferStuff, getStuff, waitLog } from "./helpers"; + +// Import the platform specific packages +import { AlgorandPlatform } from "@wormhole-foundation/connect-sdk-algorand"; +import { SolanaPlatform } from "@wormhole-foundation/connect-sdk-solana"; +import { EvmPlatform } from "@wormhole-foundation/connect-sdk-evm"; + +// Register the protocols +import "@wormhole-foundation/connect-sdk-algorand-core"; +import "@wormhole-foundation/connect-sdk-algorand-tokenbridge"; +import "@wormhole-foundation/connect-sdk-solana-core"; +import "@wormhole-foundation/connect-sdk-solana-tokenbridge"; +import "@wormhole-foundation/connect-sdk-evm-core"; +import "@wormhole-foundation/connect-sdk-evm-tokenbridge"; + +(async function () { + // init Wormhole object, passing config for which network + // to use (e.g. Mainnet/Testnet) and what Platforms to support + const wh = new Wormhole("Testnet", [AlgorandPlatform, SolanaPlatform, EvmPlatform]); + + // Grab chain Contexts -- these hold a reference to a cached rpc client + const sendChain = wh.getChain("Avalanche"); + const rcvChain = wh.getChain("Algorand"); + + // shortcut to allow transferring native gas token + const token: TokenId | "native" = "native"; + + // Normalized given token decimals later but can just pass bigints as base units + // Note: The Token bridge will dedust past 8 decimals + // this means any amount specified past that point will be returned + // to the caller + const amount = "0.1"; + + // With automatic set to true, perform an automatic transfer. This will invoke a relayer + // contract intermediary that knows to pick up the transfers + // With automatic set to false, perform a manual transfer from source to destination + // of the token + // On the destination side, a wrapped version of the token will be minted + // to the address specified in the transfer VAA + const automatic = false; + + // The automatic relayer has the ability to deliver some native gas funds to the destination account + // The amount specified for native gas will be swapped for the native gas token according + // to the swap rate provided by the contract, denominated in native gas tokens + const nativeGas = automatic ? "0.01" : undefined; + + // Get signer from local key but anything that implements + // Signer interface (e.g. wrapper around web wallet) should work + const source = await getStuff(sendChain); + const destination = await getStuff(rcvChain); + + // Used to normalize the amount to account for the tokens decimals + const decimals = + token === "native" + ? BigInt(sendChain.config.nativeTokenDecimals) + : await wh.getDecimals(sendChain.chain, token); + + // Set this to the transfer txid of the initiating transaction to recover a token transfer + // and attempt to fetch details about its progress. + let recoverTxid = undefined; + // recoverTxid = + // "2daoPz9KyVkG8WGztfatMRx3EKbiRSUVGKAoCST9286eGrzXg5xowafBUUKfd3JrHzvd4AwoH57ujWaJ72k6oiCY"; + + // Finally create and perform the transfer given the parameters set above + const xfer = !recoverTxid + ? // Perform the token transfer + await tokenTransfer(wh, { + token, + amount: normalizeAmount(amount, decimals), + source, + destination, + delivery: { + automatic, + nativeGas: nativeGas ? normalizeAmount(nativeGas, decimals) : undefined, + }, + }) + : // Recover the transfer from the originating txid + await TokenTransfer.from(wh, { + chain: source.chain.chain, + txid: recoverTxid, + }); + + // Log out the results + console.log(xfer); +})(); + +async function tokenTransfer( + wh: Wormhole, + route: { + token: TokenId | "native"; + amount: bigint; + source: TransferStuff; + destination: TransferStuff; + delivery?: { + automatic: boolean; + nativeGas?: bigint; + }; + payload?: Uint8Array; + }, + roundTrip?: boolean, +): Promise> { + // Create a TokenTransfer object to track the state of + // the transfer over time + const xfer = await wh.tokenTransfer( + route.token, + route.amount, + route.source.address, + route.destination.address, + route.delivery?.automatic ?? false, + route.payload, + route.delivery?.nativeGas, + ); + + if (xfer.transfer.automatic) { + const quote = await TokenTransfer.quoteTransfer( + route.source.chain, + route.destination.chain, + xfer.transfer, + ); + console.log(quote); + + if (quote.destinationToken.amount < 0) + throw "The amount requested is too low to cover the fee and any native gas requested."; + } + + // 1) Submit the transactions to the source chain, passing a signer to sign any txns + console.log("Starting transfer"); + const srcTxids = await xfer.initiateTransfer(route.source.signer); + console.log(`Started transfer: `, srcTxids); + + // If automatic, we're done + if (route.delivery?.automatic) return (await waitLog(xfer)) as TokenTransfer; + + // 2) wait for the VAA to be signed and ready (not required for auto transfer) + console.log("Getting Attestation"); + const attestIds = await xfer.fetchAttestation(60_000); + console.log(`Got Attestation: `, attestIds); + + // 3) redeem the VAA on the dest chain + console.log("Completing Transfer"); + const destTxids = await xfer.completeTransfer(route.destination.signer); + console.log(`Completed Transfer: `, destTxids); + + // No need to send back, dip + if (!roundTrip) return xfer; + + // We can look up the destination asset for this transfer given the context of + // the sending chain and token and destination chain + const token = await TokenTransfer.lookupDestinationToken( + route.source.chain, + route.destination.chain, + xfer.transfer, + ); + console.log(token); + + // The wrapped token may have a different number of decimals + // to make things easy, lets just send the amount from the VAA back + const amount = xfer.vaas![0]!.vaa!.payload.token.amount; + return await tokenTransfer(wh, { + ...route, + token, + amount, + source: route.destination, + destination: route.source, + }); +} diff --git a/platforms/algorand/README.md b/platforms/algorand/README.md new file mode 100644 index 000000000..36a03e3bf --- /dev/null +++ b/platforms/algorand/README.md @@ -0,0 +1,5 @@ +# Algorand Context + +Supported chains: + +- Algorand diff --git a/platforms/algorand/eslintrc.json b/platforms/algorand/eslintrc.json new file mode 100644 index 000000000..94b97befc --- /dev/null +++ b/platforms/algorand/eslintrc.json @@ -0,0 +1,20 @@ +{ + "env": { + "node": true + }, + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "comma-dangle": ["error", "always-multiline"], + "semi": ["error", "always"], + "@typescript-eslint/explicit-module-boundary-types": ["error"], + "@typescript-eslint/no-non-null-assertion": ["error"], + "@typescript-eslint/no-explicit-any": ["error", { "ignoreRestArgs": true }] + } +} diff --git a/platforms/algorand/package.json b/platforms/algorand/package.json new file mode 100644 index 000000000..679066d09 --- /dev/null +++ b/platforms/algorand/package.json @@ -0,0 +1,48 @@ +{ + "name": "@wormhole-foundation/connect-sdk-algorand", + "version": "0.3.0-beta.3", + "repository": { + "type": "git", + "url": "git+https://github.com/wormhole-foundation/connect-sdk.git" + }, + "bugs": { + "url": "https://github.com/wormhole-foundation/connect-sdk/issues" + }, + "homepage": "https://github.com/wormhole-foundation/connect-sdk#readme", + "directories": { + "test": "tests" + }, + "license": "Apache-2.0", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "author": "", + "description": "SDK for Algorand, used in conjunction with @wormhole-foundation/connect-sdk", + "files": [ + "dist/**/*" + ], + "keywords": [ + "wormhole", + "sdk", + "typescript", + "connect", + "algorand" + ], + "engines": { + "node": ">=16" + }, + "sideEffects": false, + "scripts": { + "build:cjs": "tsc -p ./tsconfig.cjs.json", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build": "npm run build:cjs && npm run build:esm", + "rebuild": "npm run clean && npm run build:cjs && npm run build:esm", + "clean": "rm -rf ./dist && rm -f ./*.tsbuildinfo", + "lint": "npm run prettier && eslint --fix", + "prettier": "prettier --write ./src" + }, + "dependencies": { + "@wormhole-foundation/connect-sdk": "^0.3.0-beta.3", + "algosdk": "2.7.0" + } +} \ No newline at end of file diff --git a/platforms/algorand/protocols/core/package.json b/platforms/algorand/protocols/core/package.json new file mode 100644 index 000000000..65b183a6c --- /dev/null +++ b/platforms/algorand/protocols/core/package.json @@ -0,0 +1,48 @@ +{ + "name": "@wormhole-foundation/connect-sdk-algorand-core", + "version": "0.3.0-beta.3", + "repository": { + "type": "git", + "url": "git+https://github.com/wormhole-foundation/connect-sdk.git" + }, + "bugs": { + "url": "https://github.com/wormhole-foundation/connect-sdk/issues" + }, + "homepage": "https://github.com/wormhole-foundation/connect-sdk#readme", + "directories": { + "test": "tests" + }, + "license": "Apache-2.0", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "author": "", + "description": "SDK for Algorand, used in conjunction with @wormhole-foundation/connect-sdk", + "files": [ + "dist/**/*" + ], + "keywords": [ + "wormhole", + "sdk", + "typescript", + "connect", + "algorand" + ], + "engines": { + "node": ">=16" + }, + "sideEffects": false, + "scripts": { + "build:cjs": "tsc -p ./tsconfig.cjs.json", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build": "npm run build:cjs && npm run build:esm", + "rebuild": "npm run clean && npm run build:cjs && npm run build:esm", + "clean": "rm -rf ./dist && rm -f ./*.tsbuildinfo", + "lint": "npm run prettier && eslint --fix", + "prettier": "prettier --write ./src" + }, + "dependencies": { + "@wormhole-foundation/connect-sdk": "^0.3.0-beta.3", + "@wormhole-foundation/connect-sdk-algorand": "^0.3.0-beta.3" + } +} \ No newline at end of file diff --git a/platforms/algorand/protocols/core/src/core.ts b/platforms/algorand/protocols/core/src/core.ts new file mode 100644 index 000000000..934ea9393 --- /dev/null +++ b/platforms/algorand/protocols/core/src/core.ts @@ -0,0 +1,125 @@ +import { + AccountAddress, + ChainId, + ChainsConfig, + Contracts, + Network, + PayloadLiteral, + UniversalAddress, + UnsignedTransaction, + VAA, + WormholeCore, + WormholeMessageId, + toChainId, +} from "@wormhole-foundation/connect-sdk"; +import { + AlgorandChains, + AlgorandPlatform, + AlgorandPlatformType, + AlgorandUnsignedTransaction, + AnyAlgorandAddress, +} from "@wormhole-foundation/connect-sdk-algorand"; +import { Algodv2, bytesToBigInt, decodeAddress, getApplicationAddress, modelsv2 } from "algosdk"; + +export class AlgorandWormholeCore + implements WormholeCore +{ + readonly chainId: ChainId; + readonly coreAppId: bigint; + readonly coreAppAddress: string; + readonly tokenBridgeAppId: bigint; + readonly tokenBridgeAppAddress: string; + + private constructor( + readonly network: N, + readonly chain: C, + readonly connection: Algodv2, + readonly contracts: Contracts, + ) { + this.chainId = toChainId(chain); + + if (!contracts.coreBridge) { + throw new Error(`Core contract address for chain ${chain} not found`); + } + const core = BigInt(contracts.coreBridge); + this.coreAppId = core; + this.coreAppAddress = getApplicationAddress(core); + + if (!contracts.tokenBridge) { + throw new Error(`TokenBridge contract address for chain ${chain} not found`); + } + const tokenBridge = BigInt(contracts.tokenBridge); + this.tokenBridgeAppId = tokenBridge; + this.tokenBridgeAppAddress = getApplicationAddress(tokenBridge); + } + + verifyMessage( + sender: AccountAddress, + vaa: VAA, + ): AsyncGenerator, any, unknown> { + throw new Error("Method not implemented."); + } + + static async fromRpc( + connection: Algodv2, + config: ChainsConfig, + ): Promise> { + const [network, chain] = await AlgorandPlatform.chainFromRpc(connection); + const conf = config[chain]!; + if (conf.network !== network) + throw new Error(`Network mismatch: ${conf.network} !== ${network}`); + return new AlgorandWormholeCore(network as N, chain, connection, conf.contracts); + } + + async *publishMessage( + sender: AnyAlgorandAddress, + message: string | Uint8Array, + ): AsyncGenerator> { + throw new Error("Method not implemented."); + } + + async parseTransaction(txid: string): Promise { + console.log("Txid: ", txid); + const result = await this.connection.pendingTransactionInformation(txid).do(); + console.log("Result: ", result); + + // QUESTIONBW: To make this work, I had to use the tokenBridgeAppId. Expected? + const emitterAddr = new UniversalAddress(this.getEmitterAddressAlgorand(this.tokenBridgeAppId)); + console.log("parseTransaction emitterAddr: ", emitterAddr); + + const sequence = this.parseSequenceFromLogAlgorand(result); + console.log("sequence: ", sequence); + return [ + { + chain: this.chain, + emitter: emitterAddr, + sequence, + } as WormholeMessageId, + ]; + } + + private getEmitterAddressAlgorand(appId: bigint): string { + const appAddr: string = getApplicationAddress(appId); + const decAppAddr: Uint8Array = decodeAddress(appAddr).publicKey; + const hexAppAddr: string = Buffer.from(decAppAddr).toString("hex"); + console.log("core.ts Emitter address: ", hexAppAddr); + return hexAppAddr; + } + + private parseSequenceFromLogAlgorand(result: Record): bigint { + let sequence: bigint | undefined; + const ptr = modelsv2.PendingTransactionResponse.from_obj_for_encoding(result); + if (ptr.innerTxns) { + const innerTxns = ptr.innerTxns; + innerTxns.forEach((txn) => { + if (txn?.logs && txn.logs.length > 0 && txn.logs[0]) { + sequence = bytesToBigInt(txn.logs[0].subarray(0, 8)); + } + }); + } + if (!sequence) { + throw new Error("Sequence not found"); + } + return sequence; + } +} diff --git a/platforms/algorand/protocols/core/src/index.ts b/platforms/algorand/protocols/core/src/index.ts new file mode 100644 index 000000000..09ae9889a --- /dev/null +++ b/platforms/algorand/protocols/core/src/index.ts @@ -0,0 +1,15 @@ +import { _platform } from "@wormhole-foundation/connect-sdk-algorand"; +import { registerProtocol } from "@wormhole-foundation/connect-sdk"; +import { AlgorandWormholeCore } from "./core"; + +declare global { + namespace WormholeNamespace { + export interface PlatformToProtocolMapping { + Algorand: {}; + } + } +} + +registerProtocol(_platform, "WormholeCore", AlgorandWormholeCore); + +export * from "./core"; diff --git a/platforms/algorand/protocols/core/tsconfig.cjs.json b/platforms/algorand/protocols/core/tsconfig.cjs.json new file mode 100644 index 000000000..73a0e681f --- /dev/null +++ b/platforms/algorand/protocols/core/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.cjs.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist/cjs", + "rootDir": "src" + } +} diff --git a/platforms/algorand/protocols/core/tsconfig.esm.json b/platforms/algorand/protocols/core/tsconfig.esm.json new file mode 100644 index 000000000..a9c110d37 --- /dev/null +++ b/platforms/algorand/protocols/core/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.esm.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist/esm", + "rootDir": "src" + } +} diff --git a/platforms/algorand/protocols/core/typedoc.json b/platforms/algorand/protocols/core/typedoc.json new file mode 100644 index 000000000..f2fbd427c --- /dev/null +++ b/platforms/algorand/protocols/core/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + } \ No newline at end of file diff --git a/platforms/algorand/protocols/tokenBridge/package.json b/platforms/algorand/protocols/tokenBridge/package.json new file mode 100644 index 000000000..06217910f --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/package.json @@ -0,0 +1,48 @@ +{ + "name": "@wormhole-foundation/connect-sdk-algorand-tokenbridge", + "version": "0.3.0-beta.3", + "repository": { + "type": "git", + "url": "git+https://github.com/wormhole-foundation/connect-sdk.git" + }, + "bugs": { + "url": "https://github.com/wormhole-foundation/connect-sdk/issues" + }, + "homepage": "https://github.com/wormhole-foundation/connect-sdk#readme", + "directories": { + "test": "tests" + }, + "license": "Apache-2.0", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "author": "", + "description": "SDK for Algorand, used in conjunction with @wormhole-foundation/connect-sdk", + "files": [ + "dist/**/*" + ], + "keywords": [ + "wormhole", + "sdk", + "typescript", + "connect", + "algorand" + ], + "engines": { + "node": ">=16" + }, + "sideEffects": false, + "scripts": { + "build:cjs": "tsc -p ./tsconfig.cjs.json", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build": "npm run build:cjs && npm run build:esm", + "rebuild": "npm run clean && npm run build:cjs && npm run build:esm", + "clean": "rm -rf ./dist && rm -f ./*.tsbuildinfo", + "lint": "npm run prettier && eslint --fix", + "prettier": "prettier --write ./src" + }, + "dependencies": { + "@wormhole-foundation/connect-sdk": "^0.3.0-beta.3", + "@wormhole-foundation/connect-sdk-algorand": "^0.3.0-beta.3" + } +} \ No newline at end of file diff --git a/platforms/algorand/protocols/tokenBridge/src/BigVarint.ts b/platforms/algorand/protocols/tokenBridge/src/BigVarint.ts new file mode 100644 index 000000000..1410d1309 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/BigVarint.ts @@ -0,0 +1,57 @@ +// Forever grateful to https://github.com/joeltg/big-varint/blob/main/src/unsigned.ts +const LIMIT = BigInt(0x7f); + +export function encodingLength(value: bigint): number { + let i = 0; + + for (; value >= BigInt(0x80); i++) { + value >>= BigInt(7); + } + + return i + 1; +} + +export function encode(i: bigint, buffer?: ArrayBuffer, byteOffset?: number): Uint8Array { + if (i < BigInt(0)) { + throw new RangeError("value must be unsigned"); + } + + const byteLength = encodingLength(i); + buffer = buffer || new ArrayBuffer(byteLength); + byteOffset = byteOffset || 0; + if (buffer.byteLength < byteOffset + byteLength) { + throw new RangeError("the buffer is too small to encode the number at the offset"); + } + + const array = new Uint8Array(buffer, byteOffset); + + let offset = 0; + while (LIMIT < i) { + array[offset++] = Number(i & LIMIT) | 0x80; + i >>= BigInt(7); + } + + array[offset] = Number(i); + + return array; +} + +export function encodeHex(i: bigint, buffer?: ArrayBuffer, byteOffset?: number): string { + return Buffer.from(encode(i, buffer, byteOffset)).toString("hex"); +} + +export function decode(data: Uint8Array, offset = 0): bigint { + let i = BigInt(0); + let n = 0; + let b: number | undefined; + do { + b = data[offset + n]; + if (b === undefined) { + throw new RangeError("offset out of range"); + } + + i += BigInt(b & 0x7f) << BigInt(n * 7); + n++; + } while (0x80 <= b); + return i; +} diff --git a/platforms/algorand/protocols/tokenBridge/src/TmplSig.ts b/platforms/algorand/protocols/tokenBridge/src/TmplSig.ts new file mode 100644 index 000000000..9318ac044 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/TmplSig.ts @@ -0,0 +1,59 @@ +import { Algodv2, LogicSigAccount } from "algosdk"; +import { id } from "ethers"; +import { encodeHex } from "./bigVarint"; +import { hexToUint8Array } from "./utilities"; + +// This is the data structure to be populated in the call to populate() below +// Yes, it needs to be filled out before calling populate() +interface IPopulateData { + appId: bigint; + appAddress: string; + addrIdx: bigint; + emitterId: string; +} +export type PopulateData = Required; + +export class TmplSig { + algoClient: Algodv2; + sourceHash: string; + bytecode: Uint8Array; + + constructor(algoClient: Algodv2) { + this.algoClient = algoClient; + this.sourceHash = ""; + this.bytecode = new Uint8Array(); + } + + async compile(source: string) { + const hash = id(source); + if (hash !== this.sourceHash) { + const response = await this.algoClient.compile(source).do(); + this.bytecode = new Uint8Array(Buffer.from(response.result, "base64")); + this.sourceHash = hash; + } + } + + /** + * Populate data in the TEAL source and return the LogicSig object based on the resulting compiled bytecode. + * @param data The data to populate fields with. + * @notes emitterId must be prefixed with '0x'. appAddress must be decoded with algoSDK and prefixed with '0x'. + * @returns A LogicSig object. + */ + async populate(data: PopulateData): Promise { + const byteString: string = [ + "0620010181", + encodeHex(data.addrIdx), + "4880", + encodeHex(BigInt(data.emitterId.length / 2)), + data.emitterId, + "483110810612443119221244311881", + encodeHex(data.appId), + "1244312080", + encodeHex(BigInt(data.appAddress.length / 2)), + data.appAddress, + "124431018100124431093203124431153203124422", + ].join(""); + this.bytecode = hexToUint8Array(byteString); + return new LogicSigAccount(this.bytecode); + } +} diff --git a/platforms/algorand/protocols/tokenBridge/src/apps.ts b/platforms/algorand/protocols/tokenBridge/src/apps.ts new file mode 100644 index 000000000..e680030bf --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/apps.ts @@ -0,0 +1,132 @@ +import { Algodv2, LogicSigAccount, decodeAddress, getApplicationAddress, modelsv2 } from "algosdk"; +import { LogicSigAccountInfo } from "./types"; +import { PopulateData, TmplSig } from "./tmplSig"; +import { hex } from "@wormhole-foundation/sdk-base/src/utils/encoding"; + +const accountExistsCache = new Set<[bigint, string]>(); + +export function getEmitterAddressAlgorand(appId: bigint): string { + console.log("appId: ", appId); + const appAddr: string = getApplicationAddress(appId); + const decAppAddr: Uint8Array = decodeAddress(appAddr).publicKey; + const hexAppAddr: string = hex.encode(decAppAddr); + console.log("Emitter address: ", hexAppAddr); + return hexAppAddr; +} + +/** + * Returns the local data for an application ID + * @param client Algodv2 client + * @param appId Application ID of interest + * @param address Address of the account + * @returns Promise with Uint8Array of data squirreled away + */ +export async function decodeLocalState( + client: Algodv2, + appId: bigint, + address: string, +): Promise { + let appState; + const ai = await client.accountInformation(address).do(); + const acctInfo = modelsv2.Account.from_obj_for_encoding(ai); + for (const app of acctInfo.appsLocalState!) { + if (BigInt(app.id) === appId) { + appState = app.keyValue; + break; + } + } + + let ret = Buffer.alloc(0); + let empty = Buffer.alloc(0); + if (appState) { + const e = Buffer.alloc(127); + const m = Buffer.from("meta"); + + let sk: string[] = []; + let vals: Map = new Map(); + for (const kv of appState) { + const k = Buffer.from(kv.key, "base64"); + const key: number = k.readInt8(); + if (!Buffer.compare(k, m)) { + continue; + } + const v: Buffer = Buffer.from(kv.value.bytes, "base64"); + if (Buffer.compare(v, e)) { + vals.set(key.toString(), v); + sk.push(key.toString()); + } + } + + sk.sort((a, b) => a.localeCompare(b, "en", { numeric: true })); + + sk.forEach((v) => { + ret = Buffer.concat([ret, vals.get(v) || empty]); + }); + } + return new Uint8Array(ret); +} + +/** + * Checks to see if the account exists for the application + * @param client An Algodv2 client + * @param appId Application ID + * @param acctAddr Account address to check + * @returns True, if account exists for application, False otherwise + */ +export async function accountExists( + client: Algodv2, + appId: bigint, + acctAddr: string, +): Promise { + if (accountExistsCache.has([appId, acctAddr])) return true; + + let ret = false; + try { + const acctInfoResp = await client.accountInformation(acctAddr).do(); + const acctInfo = modelsv2.Account.from_obj_for_encoding(acctInfoResp); + const als = acctInfo.appsLocalState; + if (!als) { + return ret; + } + als.forEach((app) => { + if (BigInt(app.id) === appId) { + accountExistsCache.add([appId, acctAddr]); + ret = true; + return; + } + }); + } catch (e) {} + return ret; +} + +/** + * Calculates the logic sig account for the application + * @param client An Algodv2 client + * @param appId Application ID + * @param appIndex Application index + * @param emitterId Emitter address + * @returns Promise with LogicSigAccountInfo + */ +export async function calcLogicSigAccount( + client: Algodv2, + appId: bigint, + appIndex: bigint, + emitterId: string, +): Promise { + let data: PopulateData = { + addrIdx: appIndex, + appAddress: getEmitterAddressAlgorand(appId), + appId: appId, + emitterId: emitterId, + }; + + const ts: TmplSig = new TmplSig(client); + const lsa: LogicSigAccount = await ts.populate(data); + const sigAddr: string = lsa.address(); + + const doesExist: boolean = await accountExists(client, appId, sigAddr); + return { + lsa, + doesExist, + }; +} diff --git a/platforms/algorand/protocols/tokenBridge/src/assets.ts b/platforms/algorand/protocols/tokenBridge/src/assets.ts new file mode 100644 index 000000000..ab63bf82d --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/assets.ts @@ -0,0 +1,197 @@ +import { Chain, ChainId, toChainId } from "@wormhole-foundation/connect-sdk"; +import { + CHAIN_ID_ALGORAND, + TransactionSignerPair, +} from "@wormhole-foundation/connect-sdk-algorand"; +import { + Algodv2, + Transaction, + bigIntToBytes, + bytesToBigInt, + getApplicationAddress, + makeApplicationOptInTxnFromObject, + makePaymentTxnWithSuggestedParamsFromObject, + signLogicSigTransaction, + modelsv2 +} from "algosdk"; +import { OptInResult, WormholeWrappedInfo } from "./types"; +import { safeBigIntToNumber } from "./utilities"; +import { calcLogicSigAccount, decodeLocalState } from "./apps"; +import { hex } from "@wormhole-foundation/sdk-base/src/utils/encoding"; +import { SEED_AMT } from "./constants"; + +const accountExistsCache = new Set<[bigint, string]>(); + +/** + * Returns a boolean if the asset is wrapped + * @param client Algodv2 client + * @param tokenBridgeId Application ID of the token bridge + * @param assetId Algorand asset index + * @returns Promise with True if wrapped, False otherwise + */ +export async function getIsWrappedAssetOnAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + assetId: bigint, +): Promise { + if (assetId === BigInt(0)) { + return false; + } + const tbAddr: string = getApplicationAddress(tokenBridgeId); + const assetInfoResp = await client.getAssetByID(Number(assetId)).do(); + const asset = modelsv2.Asset.from_obj_for_encoding(assetInfoResp); + const creatorAddr = asset.params.creator; + const creatorAcctInfoResp = await client.accountInformation(creatorAddr).exclude("all").do(); + const creator = modelsv2.Account.from_obj_for_encoding(creatorAcctInfoResp); + const isWrapped: boolean = creator?.authAddr === tbAddr; + return isWrapped; +} + +/** + * Returns an origin chain and asset address on {originChain} for a provided Wormhole wrapped address + * @param client Algodv2 client + * @param tokenBridgeId Application ID of the token bridge + * @param assetId Algorand asset index + * @returns Wrapped Wormhole information structure + */ +export async function getOriginalAssetOffAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + assetId: bigint, +): Promise { + let retVal: WormholeWrappedInfo = { + isWrapped: false, + chainId: CHAIN_ID_ALGORAND, + assetAddress: new Uint8Array(), + }; + retVal.isWrapped = await getIsWrappedAssetOnAlgorand(client, tokenBridgeId, assetId); + if (!retVal.isWrapped) { + retVal.assetAddress = bigIntToBytes(assetId, 32); + return retVal; + } + const assetInfoResp = await client.getAssetByID(safeBigIntToNumber(assetId)).do(); + const assetInfo = modelsv2.Asset.from_obj_for_encoding(assetInfoResp); + const lsa = assetInfo.params.creator; + const dls = await decodeLocalState(client, tokenBridgeId, lsa); + const dlsBuffer: Buffer = Buffer.from(dls); + retVal.chainId = dlsBuffer.readInt16BE(92) as ChainId; + retVal.assetAddress = new Uint8Array(dlsBuffer.subarray(60, 60 + 32)); + return retVal; +} + +/** + * Returns an origin chain and asset address on {originChain} for a provided Wormhole wrapped address + * @param client Algodv2 client + * @param tokenBridgeId Application ID of the token bridge + * @param assetId Algorand asset index + * @returns Promise with the Algorand asset index or null + */ +export async function getWrappedAssetOnAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + chain: ChainId | Chain, + contract: string, +): Promise { + const chainId = toChainId(chain); + if (chainId === CHAIN_ID_ALGORAND) { + return bytesToBigInt(hex.decode(contract)); + } else { + let { lsa, doesExist } = await calcLogicSigAccount( + client, + tokenBridgeId, + BigInt(chainId), + contract, + ); + if (!doesExist) { + return null; + } + let asset: Uint8Array = await decodeLocalState(client, tokenBridgeId, lsa.address()); + if (asset.length > 8) { + const tmp = Buffer.from(asset.slice(0, 8)); + return tmp.readBigUInt64BE(0); + } else return null; + } +} + +/** + * Calculates the logic sig account for the application + * @param client An Algodv2 client + * @param senderAddr Sender address + * @param appId Application ID + * @param appIndex Application index + * @param emitterId Emitter address + * @returns Address and array of TransactionSignerPairs + */ +export async function optIn( + client: Algodv2, + senderAddr: string, + appId: bigint, + appIndex: bigint, + emitterId: string, +): Promise { + const appAddr: string = getApplicationAddress(appId); + + // Check to see if we need to create this + const { doesExist, lsa } = await calcLogicSigAccount(client, appId, appIndex, emitterId); + const sigAddr: string = lsa.address(); + let txs: TransactionSignerPair[] = []; + if (!doesExist) { + // These are the suggested params from the system + const params = await client.getTransactionParams().do(); + const seedTxn = makePaymentTxnWithSuggestedParamsFromObject({ + from: senderAddr, + to: sigAddr, + amount: SEED_AMT, + suggestedParams: params, + }); + seedTxn.fee = seedTxn.fee * 2; + txs.push({ tx: seedTxn, signer: null }); + const optinTxn = makeApplicationOptInTxnFromObject({ + from: sigAddr, + suggestedParams: params, + appIndex: safeBigIntToNumber(appId), + rekeyTo: appAddr, + }); + optinTxn.fee = 0; + txs.push({ + tx: optinTxn, + signer: { + addr: lsa.address(), + signTxn: (txn: Transaction) => Promise.resolve(signLogicSigTransaction(txn, lsa).blob), + }, + }); + + accountExistsCache.add([appId, lsa.address()]); + } + return { + addr: sigAddr, + txs, + }; +} + +/** + * Checks if the asset has been opted in by the receiver + * @param client Algodv2 client + * @param asset Algorand asset index + * @param receiver Account address + * @returns Promise with True if the asset was opted in, False otherwise + */ +export async function assetOptinCheck( + client: Algodv2, + asset: bigint, + receiver: string, +): Promise { + const acctInfoResp = await client.accountInformation(receiver).do(); + const acctInfo = modelsv2.Account.from_obj_for_encoding(acctInfoResp); + const assets = acctInfo.assets; + let ret = false; + assets && + assets.forEach((a) => { + const assetId = BigInt(a.assetId); + if (assetId === asset) { + ret = true; + return; + } + }); + return ret; +} diff --git a/platforms/algorand/protocols/tokenBridge/src/constants.ts b/platforms/algorand/protocols/tokenBridge/src/constants.ts new file mode 100644 index 000000000..9057c26fe --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/constants.ts @@ -0,0 +1,30 @@ +export const SEED_AMT: number = 1002000; + +export const ZERO_PAD_BYTES = "0000000000000000000000000000000000000000000000000000000000000000"; + +export const MAX_KEYS: number = 15; + +export const MAX_BYTES_PER_KEY: number = 127; + +export const BITS_PER_BYTE: number = 8; + +export const BITS_PER_KEY: number = MAX_BYTES_PER_KEY * BITS_PER_BYTE; + +export const MAX_BYTES: number = MAX_BYTES_PER_KEY * MAX_KEYS; + +export const MAX_BITS: number = BITS_PER_BYTE * MAX_BYTES; + +export const MAX_SIGS_PER_TXN: number = 6; + +export const ALGO_VERIFY_HASH = "EZATROXX2HISIRZDRGXW4LRQ46Z6IUJYYIHU3PJGP7P5IQDPKVX42N767A"; + +export const ALGO_VERIFY = new Uint8Array([ + 6, 32, 4, 1, 0, 32, 20, 38, 1, 0, 49, 32, 50, 3, 18, 68, 49, 1, 35, 18, 68, 49, 16, 129, 6, 18, + 68, 54, 26, 1, 54, 26, 3, 54, 26, 2, 136, 0, 3, 68, 34, 67, 53, 2, 53, 1, 53, 0, 40, 53, 240, 40, + 53, 241, 52, 0, 21, 53, 5, 35, 53, 3, 35, 53, 4, 52, 3, 52, 5, 12, 65, 0, 68, 52, 1, 52, 0, 52, 3, + 129, 65, 8, 34, 88, 23, 52, 0, 52, 3, 34, 8, 36, 88, 52, 0, 52, 3, 129, 33, 8, 36, 88, 7, 0, 53, + 241, 53, 240, 52, 2, 52, 4, 37, 88, 52, 240, 52, 241, 80, 2, 87, 12, 20, 18, 68, 52, 3, 129, 66, + 8, 53, 3, 52, 4, 37, 8, 53, 4, 66, 255, 180, 34, 137, +]); + +export const METADATA_REPLACE = new RegExp("\u0000", "g"); diff --git a/platforms/algorand/protocols/tokenBridge/src/index.ts b/platforms/algorand/protocols/tokenBridge/src/index.ts new file mode 100644 index 000000000..c84b98da7 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/index.ts @@ -0,0 +1,24 @@ +import { _platform } from "@wormhole-foundation/connect-sdk-algorand"; +import { registerProtocol } from "@wormhole-foundation/connect-sdk"; +import { AlgorandTokenBridge } from "./tokenBridge"; + +declare global { + namespace WormholeNamespace { + export interface PlatformToProtocolMapping { + Algorand: {}; + } + } +} + +registerProtocol(_platform, "TokenBridge", AlgorandTokenBridge); + +export * from "./apps"; +export * from "./assets"; +export * from "./constants"; +export * from "./tmplSig"; +export * from "./tokenBridge"; +export * from "./tokenBridge"; +export * from "./transfers"; +export * from "./types"; +export * from "./utilities"; +export * from "./vaa"; diff --git a/platforms/algorand/protocols/tokenBridge/src/tokenBridge.ts b/platforms/algorand/protocols/tokenBridge/src/tokenBridge.ts new file mode 100644 index 000000000..3173fc2a4 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/tokenBridge.ts @@ -0,0 +1,285 @@ +import { + AccountAddress, + Chain, + ChainAddress, + ChainId, + ChainsConfig, + Contracts, + ErrNotWrapped, + NativeAddress, + Network, + Platform, + TokenAddress, + TokenBridge, + TokenId, + UniversalAddress, + UnsignedTransaction, + serialize, + toChain, + toChainId, + toNative, +} from "@wormhole-foundation/connect-sdk"; +import { + AlgorandAddress, + AlgorandChains, + AlgorandPlatform, + AlgorandPlatformType, + AlgorandUnsignedTransaction, + AlgorandZeroAddress, + AnyAlgorandAddress, + TransactionSignerPair, +} from "@wormhole-foundation/connect-sdk-algorand"; +import { Algodv2, bigIntToBytes, bytesToBigInt, getApplicationAddress } from "algosdk"; +import { + getIsWrappedAssetOnAlgorand, + getOriginalAssetOffAlgorand, + getWrappedAssetOnAlgorand, +} from "./assets"; +import { + attestFromAlgorand, + getIsTransferCompletedAlgorand, + redeemOnAlgorand, + transferFromAlgorand, +} from "./transfers"; +import { submitVAAHeader } from "./vaa"; + +export class AlgorandTokenBridge + implements TokenBridge +{ + readonly chainId: ChainId; + readonly coreAppId: bigint; + readonly coreAppAddress: string; + readonly tokenBridgeAppId: bigint; + readonly tokenBridgeAddress: string; + + private constructor( + readonly network: N, + readonly chain: C, + readonly connection: Algodv2, + readonly contracts: Contracts, + ) { + this.chainId = toChainId(chain); + + if (!contracts.coreBridge) { + throw new Error(`Core contract address for chain ${chain} not found`); + } + const core = BigInt(contracts.coreBridge); + this.coreAppId = core; + this.coreAppAddress = getApplicationAddress(core); + + if (!contracts.tokenBridge) { + throw new Error(`TokenBridge contract address for chain ${chain} not found`); + } + const tokenBridge = BigInt(contracts.tokenBridge); + this.tokenBridgeAppId = tokenBridge; + this.tokenBridgeAddress = getApplicationAddress(tokenBridge); + } + + static async fromRpc( + provider: Algodv2, + config: ChainsConfig, + ): Promise> { + const [network, chain] = await AlgorandPlatform.chainFromRpc(provider); + + const conf = config[chain]!; + if (conf.network !== network) + throw new Error(`Network mismatch: ${conf.network} != ${network}`); + + return new AlgorandTokenBridge(network as N, chain, provider, conf.contracts); + } + + // Checks a native address to see if it's a wrapped version + async isWrappedAsset(token: TokenAddress): Promise { + const assetId = bytesToBigInt(new AlgorandAddress(token.toString()).toUint8Array()); + + const isWrapped = await getIsWrappedAssetOnAlgorand( + this.connection, + this.tokenBridgeAppId, + assetId, + ); + return isWrapped; + } + + // Returns the original asset with its foreign chain + async getOriginalAsset(token: TokenAddress): Promise { + if (!(await this.isWrappedAsset(token))) throw ErrNotWrapped(token.toString()); + + const assetId = bytesToBigInt(new AlgorandAddress(token.toString()).toUint8Array()); + + const whWrappedInfo = await getOriginalAssetOffAlgorand( + this.connection, + this.tokenBridgeAppId, + assetId, + ); + const tokenId = { + chain: toChain(whWrappedInfo.chainId), + address: new UniversalAddress(whWrappedInfo.assetAddress), + }; + return tokenId; + } + + // Returns the address of the native version of this asset + async getWrappedAsset(token: TokenId): Promise> { + const assetId = await getWrappedAssetOnAlgorand( + this.connection, + this.tokenBridgeAppId, + token.chain, + token.address.toString(), + ); + + if (assetId === null) { + throw new Error(`Algorand asset ${token.address} not found`); + } + + const nativeAddress = toNative(this.chain, bigIntToBytes(assetId, 8)); + return nativeAddress; + } + + // Checks if a wrapped version exists + async hasWrappedAsset(token: TokenId): Promise { + try { + await this.getWrappedAsset(token); + return true; + } catch (e) {} + return false; + } + + async getWrappedNative(): Promise> { + return toNative(this.chain, new AlgorandAddress(AlgorandZeroAddress).toString()); + } + + async isTransferCompleted( + vaa: TokenBridge.VAA<"Transfer" | "TransferWithPayload">, + ): Promise { + const completed = getIsTransferCompletedAlgorand( + this.connection, + this.tokenBridgeAppId, + serialize(vaa), + ); + return completed; + } + + // Creates a Token Attestation VAA containing metadata about + // the token that may be submitted to a Token Bridge on another chain + // to allow it to create a wrapped version of the token + async *createAttestation( + token_to_attest: AnyAlgorandAddress, + payer?: AnyAlgorandAddress, + ): AsyncGenerator> { + if (!payer) throw new Error("Payer required to create attestation"); + + const senderAddr = payer.toString(); + const assetId = bytesToBigInt(new AlgorandAddress(token_to_attest.toString()).toUint8Array()); + const utxns = await attestFromAlgorand( + this.connection, + this.tokenBridgeAppId, + this.coreAppId, + senderAddr.toString(), + assetId, + ); + + for (const utxn of utxns) { + yield this.createUnsignedTx(utxn, "Algorand.TokenBridge.createAttestation", true); + } + } + + // Submits the Token Attestation VAA to the Token Bridge + // to create the wrapped token represented by the data in the VAA + async *submitAttestation( + vaa: TokenBridge.VAA<"AttestMeta">, + payer?: AnyAlgorandAddress, + ): AsyncGenerator> { + if (!payer) throw new Error("Payer required to create attestation"); + + const senderAddr = payer.toString(); + const { txs } = await submitVAAHeader( + this.connection, + this.tokenBridgeAppId, + serialize(vaa), + senderAddr, + this.coreAppId, + ); + + for (const utxn of txs) { + yield this.createUnsignedTx(utxn, "Algorand.TokenBridge.submitAttestation", true); + } + } + + async *transfer( + sender: AccountAddress, + recipient: ChainAddress, + token: TokenAddress, + amount: bigint, + payload?: Uint8Array, + ): AsyncGenerator> { + const senderAddr = sender.toString(); + const assetId = + token === "native" ? BigInt(0) : bytesToBigInt(new AlgorandAddress(token).toUint8Array()); + const qty = amount; + const chain = recipient.chain; + + const receiver = recipient.address.toUniversalAddress(); + + const fee = BigInt(0); + console.log( + "About to transferFromAlgorand: ", + senderAddr, + assetId, + qty, + receiver.toString(), + chain, + fee, + ); + const utxns = await transferFromAlgorand( + this.connection, + this.tokenBridgeAppId, + this.coreAppId, + senderAddr, + assetId, + qty, + receiver, + chain, + fee, + payload, + ); + + for (const utxn of utxns) { + yield this.createUnsignedTx(utxn, "Algorand.TokenBridge.transfer", true); + } + } + + // Redeems a transfer VAA to receive the tokens on this chain + async *redeem( + sender: AccountAddress, + vaa: TokenBridge.VAA<"Transfer" | "TransferWithPayload">, + unwrapNative: boolean = true, + ): AsyncGenerator> { + const senderAddr = new AlgorandAddress(sender.toString()).toString(); + + const utxns = await redeemOnAlgorand( + this.connection, + this.tokenBridgeAppId, + this.coreAppId, + serialize(vaa), + senderAddr, + ); + + for (const utxn of utxns) { + yield this.createUnsignedTx(utxn, "Algorand.TokenBridge.redeem", true); + } + } + + private createUnsignedTx( + txReq: TransactionSignerPair, + description: string, + parallelizable: boolean = true, // Default true for Algorand atomic transaction grouping + ): AlgorandUnsignedTransaction { + return new AlgorandUnsignedTransaction( + txReq, + this.network, + this.chain, + description, + parallelizable, + ); + } +} diff --git a/platforms/algorand/protocols/tokenBridge/src/transfers.ts b/platforms/algorand/protocols/tokenBridge/src/transfers.ts new file mode 100644 index 000000000..0028f7628 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/transfers.ts @@ -0,0 +1,420 @@ +import { + Algodv2, + OnApplicationComplete, + SuggestedParams, + bigIntToBytes, + decodeAddress, + getApplicationAddress, + makeApplicationCallTxnFromObject, + makeAssetTransferTxnWithSuggestedParamsFromObject, + makePaymentTxnWithSuggestedParamsFromObject, + modelsv2 +} from "algosdk"; +import { calcLogicSigAccount, getEmitterAddressAlgorand } from "./apps"; +import { BITS_PER_KEY, MAX_BITS } from "./constants"; +import { + safeBigIntToNumber, + textToHexString, + textToUint8Array, + uint8ArrayToHex, +} from "./utilities"; +import { _parseVAAAlgorand, _submitVAAAlgorand } from "./vaa"; +import { assetOptinCheck, optIn } from "./assets"; +import { Chain, UniversalAddress, toChainId } from "@wormhole-foundation/connect-sdk"; +import { TransactionSignerPair } from "@wormhole-foundation/connect-sdk-algorand"; + +/** + * This function is used to check if a VAA has been redeemed by looking at a specific bit + * @param client AlgodV2 client + * @param appId Application Id + * @param addr Wallet address. Someone has to pay for this + * @param seq The sequence number of the redemption + * @returns True, if the bit was set and VAA was redeemed, False otherwise + */ +async function checkBitsSet( + client: Algodv2, + appId: bigint, + addr: string, + seq: bigint, +): Promise { + let retval: boolean = false; + let appState: modelsv2.TealKeyValue[] | undefined; + const acctInfoResp = await client.accountInformation(addr).do(); + const acctInfo = modelsv2.Account.from_obj_for_encoding(acctInfoResp); + const als = acctInfo.appsLocalState; + als && + als.forEach((app) => { + if (BigInt(app.id) === appId) { + appState = app.keyValue; + } + }); + if (appState?.length === 0) { + return retval; + } + + const BIG_MAX_BITS: bigint = BigInt(MAX_BITS); + const BIG_EIGHT: bigint = BigInt(8); + // Start on a MAX_BITS boundary + const start: bigint = (seq / BIG_MAX_BITS) * BIG_MAX_BITS; + // beg should be in the range [0..MAX_BITS] + const beg: number = safeBigIntToNumber(seq - start); + // s should be in the range [0..15] + const s: number = Math.floor(beg / BITS_PER_KEY); + const b: number = Math.floor((beg - s * BITS_PER_KEY) / 8); + + const key = Buffer.from(bigIntToBytes(s, 1)).toString("base64"); + appState?.forEach((kv) => { + if (kv.key === key) { + const v = Buffer.from(kv.value.bytes, "base64"); + const bt = 1 << safeBigIntToNumber(seq % BIG_EIGHT); + retval = (v[b]! & bt) != 0; // Added non-null assertion + return; + } + }); + return retval; +} + +/** + * Returns true if this transfer was completed on Algorand + * @param client AlgodV2 client + * @param appId Most likely the Token bridge ID + * @param signedVAA VAA to check + * @returns True if VAA has been redeemed, False otherwise + */ +export async function getIsTransferCompletedAlgorand( + client: Algodv2, + appId: bigint, + signedVAA: Uint8Array, +): Promise { + const parsedVAA = _parseVAAAlgorand(signedVAA); // TODO: rip this out and look for deserialize('TokenBridge:Attestation', bytes) + const seq: bigint = parsedVAA.sequence; + const chainRaw: string = parsedVAA.chainRaw; // this needs to be a hex string + const em: string = parsedVAA.emitter; // this needs to be a hex string + + const { doesExist, lsa } = await calcLogicSigAccount( + client, + appId, + seq / BigInt(MAX_BITS), + chainRaw + em, + ); + if (!doesExist) { + return false; + } + const seqAddr = lsa.address(); + const retVal: boolean = await checkBitsSet(client, appId, seqAddr, seq); + return retVal; +} + +/** + * Return the message fee for the core bridge + * @param client An Algodv2 client + * @param bridgeId The application ID of the core bridge + * @returns Promise with the message fee for the core bridge + */ +export async function getMessageFee(client: Algodv2, bridgeId: bigint): Promise { + const applInfoResp: Record = await client + .getApplicationByID(safeBigIntToNumber(bridgeId)) + .do(); + const appInfo = modelsv2.Application.from_obj_for_encoding(applInfoResp); + const globalState = appInfo.params.globalState; + const key: string = Buffer.from("MessageFee", "binary").toString("base64"); + let ret = BigInt(0); + globalState && + globalState.forEach((kv) => { + if (kv.key === key) { + ret = BigInt(kv.value.uint); + return; + } + }); + console.log("Message Fee: ", ret); + return ret; +} + +/** + * Attest an already created asset + * If you create a new asset on algorand and want to transfer it elsewhere, + * you create an attestation for it on algorand, pass that vaa to the target chain, + * submit it, and then you can transfer from Algorand to that target chain + * @param client An Algodv2 client + * @param tokenBridgeId The ID of the token bridge + * @param senderAcct The account paying fees + * @param assetId The asset index + * @returns Transaction ID + */ +export async function attestFromAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + bridgeId: bigint, + senderAddr: string, + assetId: bigint, +): Promise { + const tbAddr: string = getApplicationAddress(tokenBridgeId); + const decTbAddr: Uint8Array = decodeAddress(tbAddr).publicKey; + const aa: string = uint8ArrayToHex(decTbAddr); + const txs: TransactionSignerPair[] = []; + // "attestFromAlgorand::emitterAddr" + const { addr: emitterAddr, txs: emitterOptInTxs } = await optIn( + client, + senderAddr, + bridgeId, + BigInt(0), + aa, + ); + txs.push(...emitterOptInTxs); + + let creatorAddr = ""; + let creatorAcctInfo; + const bPgmName: Uint8Array = textToUint8Array("attestToken"); + + if (assetId !== BigInt(0)) { + const assetInfoResp = await client.getAssetByID(safeBigIntToNumber(assetId)).do(); + const assetInfo = modelsv2.Asset.from_obj_for_encoding(assetInfoResp); + const creatorAcctInfoResp = await client.accountInformation(assetInfo.params.creator).do(); + creatorAcctInfo = modelsv2.Account.from_obj_for_encoding(creatorAcctInfoResp); + if (creatorAcctInfo.authAddr === tbAddr) { + throw new Error("Cannot re-attest wormhole assets"); + } + } + const result = await optIn(client, senderAddr, tokenBridgeId, assetId, textToHexString("native")); + creatorAddr = result.addr; + txs.push(...result.txs); + + const suggParams: SuggestedParams = await client.getTransactionParams().do(); + + const firstTxn = makeApplicationCallTxnFromObject({ + from: senderAddr, + appIndex: safeBigIntToNumber(tokenBridgeId), + onComplete: OnApplicationComplete.NoOpOC, + appArgs: [textToUint8Array("nop")], + suggestedParams: suggParams, + }); + txs.push({ tx: firstTxn, signer: null }); + + const mfee = await getMessageFee(client, bridgeId); + if (mfee > BigInt(0)) { + const feeTxn = makePaymentTxnWithSuggestedParamsFromObject({ + from: senderAddr, + suggestedParams: suggParams, + to: getApplicationAddress(tokenBridgeId), + amount: mfee, + }); + txs.push({ tx: feeTxn, signer: null }); + } + + let accts: string[] = [emitterAddr, creatorAddr, getApplicationAddress(bridgeId)]; + + if (creatorAcctInfo) { + accts.push(creatorAcctInfo.address); + } + + let appTxn = makeApplicationCallTxnFromObject({ + appArgs: [bPgmName, bigIntToBytes(assetId, 8)], + accounts: accts, + appIndex: safeBigIntToNumber(tokenBridgeId), + foreignApps: [safeBigIntToNumber(bridgeId)], + foreignAssets: [safeBigIntToNumber(assetId)], + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: suggParams, + }); + if (mfee > BigInt(0)) { + appTxn.fee *= 3; + } else { + appTxn.fee *= 2; + } + txs.push({ tx: appTxn, signer: null }); + + return txs; +} + +/** + * Submits the VAA to Algorand + * @param client AlgodV2 client + * @param tokenBridgeId Token bridge ID + * @param bridgeId Core bridge ID + * @param vaa The VAA to be redeemed + * @param acct Sending account + * @returns Promise with array of TransactionSignerPair + */ +export async function redeemOnAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + bridgeId: bigint, + vaa: Uint8Array, + senderAddr: string, +): Promise { + return await _submitVAAAlgorand(client, tokenBridgeId, bridgeId, vaa, senderAddr); +} + +/** + * Transfers an asset from Algorand to a receiver on another chain + * @param client AlgodV2 client + * @param tokenBridgeId Application ID of the token bridge + * @param bridgeId Application ID of the core bridge + * @param senderAddr Sending account + * @param assetId Asset index + * @param qty Quantity to transfer + * @param receiver Receiving account + * @param chain Reeiving chain + * @param fee Transfer fee + * @param payload payload for payload3 transfers + * @returns Promise with array of TransactionSignerPair + */ +export async function transferFromAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + bridgeId: bigint, + senderAddr: string, + assetId: bigint, + qty: bigint, + receiver: UniversalAddress, + chain: Chain, + fee: bigint, + payload: Uint8Array | null = null, +): Promise { + const recipientChainId = toChainId(chain); + const tokenAddr: string = getApplicationAddress(tokenBridgeId); + const applAddr: string = getEmitterAddressAlgorand(tokenBridgeId); + const txs: TransactionSignerPair[] = []; + // "transferAsset" + const { addr: emitterAddr, txs: emitterOptInTxs } = await optIn( + client, + senderAddr, + bridgeId, + BigInt(0), + applAddr, + ); + txs.push(...emitterOptInTxs); + let creator = ""; + let creatorAcct: modelsv2.Account | undefined; + let wormhole: boolean = false; + if (assetId !== BigInt(0)) { + const assetInfoResp: Record = await client + .getAssetByID(safeBigIntToNumber(assetId)) + .do(); + const asset = modelsv2.Asset.from_obj_for_encoding(assetInfoResp); + creator = asset.params.creator; + const creatorAcctInfoResp = await client.accountInformation(creator).do(); + creatorAcct = modelsv2.Account.from_obj_for_encoding(creatorAcctInfoResp); + const authAddr = creatorAcct.authAddr; + if (authAddr === tokenAddr) { + wormhole = true; + } + } + + const params: SuggestedParams = await client.getTransactionParams().do(); + const msgFee: bigint = await getMessageFee(client, bridgeId); + if (msgFee > 0) { + const payTxn = makePaymentTxnWithSuggestedParamsFromObject({ + from: senderAddr, + suggestedParams: params, + to: getApplicationAddress(tokenBridgeId), + amount: msgFee, + }); + txs.push({ tx: payTxn, signer: null }); + } + if (!wormhole) { + const bNat = Buffer.from("native", "binary").toString("hex"); + // "creator" + const result = await optIn(client, senderAddr, tokenBridgeId, assetId, bNat); + creator = result.addr; + txs.push(...result.txs); + } + + if (assetId !== BigInt(0) && !(await assetOptinCheck(client, assetId, creator))) { + // Looks like we need to optin + const payTxn = makePaymentTxnWithSuggestedParamsFromObject({ + from: senderAddr, + to: creator, + amount: 100000, + suggestedParams: params, + }); + txs.push({ tx: payTxn, signer: null }); + // The tokenid app needs to do the optin since it has signature authority + const bOptin: Uint8Array = textToUint8Array("optin"); + let txn = makeApplicationCallTxnFromObject({ + from: senderAddr, + appIndex: safeBigIntToNumber(tokenBridgeId), + onComplete: OnApplicationComplete.NoOpOC, + appArgs: [bOptin, bigIntToBytes(assetId, 8)], + foreignAssets: [safeBigIntToNumber(assetId)], + accounts: [creator], + suggestedParams: params, + }); + txn.fee *= 2; + txs.push({ tx: txn, signer: null }); + } + const t = makeApplicationCallTxnFromObject({ + from: senderAddr, + appIndex: safeBigIntToNumber(tokenBridgeId), + onComplete: OnApplicationComplete.NoOpOC, + appArgs: [textToUint8Array("nop")], + suggestedParams: params, + }); + txs.push({ tx: t, signer: null }); + + let accounts: string[] = []; + if (assetId === BigInt(0)) { + const t = makePaymentTxnWithSuggestedParamsFromObject({ + from: senderAddr, + to: creator, + amount: qty, + suggestedParams: params, + }); + txs.push({ tx: t, signer: null }); + accounts = [emitterAddr, creator, creator]; + } else { + const t = makeAssetTransferTxnWithSuggestedParamsFromObject({ + from: senderAddr, + to: creator, + suggestedParams: params, + amount: qty, + assetIndex: safeBigIntToNumber(assetId), + }); + txs.push({ tx: t, signer: null }); + + accounts = creatorAcct?.address + ? [emitterAddr, creator, creatorAcct.address] + : [emitterAddr, creator]; + } + console.log("transferFromAlgorand receiver: ", receiver); + const receiverBytes = new Uint8Array(receiver.toUint8Array()); + console.log("receiverBytes: ", receiverBytes); + + let args = [ + textToUint8Array("sendTransfer"), + bigIntToBytes(assetId, 8), + bigIntToBytes(qty, 8), + receiverBytes, + bigIntToBytes(recipientChainId, 8), + bigIntToBytes(fee, 8), + ]; + console.log("Args: ", args); + if (payload !== null) { + args.push(payload); + } + let acTxn = makeApplicationCallTxnFromObject({ + from: senderAddr, + appIndex: safeBigIntToNumber(tokenBridgeId), + onComplete: OnApplicationComplete.NoOpOC, + appArgs: args, + foreignApps: [safeBigIntToNumber(bridgeId)], + foreignAssets: [safeBigIntToNumber(assetId)], + accounts: accounts, + suggestedParams: params, + }); + acTxn.fee *= 2; + txs.push({ tx: acTxn, signer: null }); + return txs; +} + +// TODO: Need to figure out what to do with this +export async function createWrappedOnAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + bridgeId: bigint, + senderAddr: string, + attestVAA: Uint8Array, +): Promise { + return await _submitVAAAlgorand(client, tokenBridgeId, bridgeId, attestVAA, senderAddr); +} diff --git a/platforms/algorand/protocols/tokenBridge/src/types.ts b/platforms/algorand/protocols/tokenBridge/src/types.ts new file mode 100644 index 000000000..2035a4194 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/types.ts @@ -0,0 +1,66 @@ +import { ChainId } from "@wormhole-foundation/connect-sdk"; +import { LogicSigAccount } from "algosdk"; +import { TransactionSignerPair } from "@wormhole-foundation/connect-sdk-algorand"; + +export type OptInResult = { + addr: string; + txs: TransactionSignerPair[]; +}; + +export interface WormholeWrappedInfo { + isWrapped: boolean; + chainId: ChainId; + assetAddress: Uint8Array; +} + +export type LogicSigAccountInfo = { + lsa: LogicSigAccount; + doesExist: boolean; +}; + +export type ParsedVAA = { + version: number; + index: number; + siglen: number; + signatures: Uint8Array; + sigs: Uint8Array[]; + digest: Uint8Array; + timestamp: number; + nonce: number; + chainRaw: string; + chain: number; + emitter: string; + sequence: bigint; + consistency: number; + Meta: + | "Unknown" + | "TokenBridge" + | "TokenBridge RegisterChain" + | "TokenBridge UpgradeContract" + | "CoreGovernance" + | "TokenBridge Attest" + | "TokenBridge Transfer" + | "TokenBridge Transfer With Payload"; + module?: Uint8Array; + action?: number; + targetChain?: number; + EmitterChainID?: number; + targetEmitter?: Uint8Array; + newContract?: Uint8Array; + NewGuardianSetIndex?: number; + Type?: number; + Contract?: string; + FromChain?: number; + Decimals?: number; + Symbol?: Uint8Array; + Name?: Uint8Array; + TokenId?: Uint8Array; + Amount?: Uint8Array; + ToAddress?: Uint8Array; + ToChain?: number; + Fee?: Uint8Array; + FromAddress?: Uint8Array; + Payload?: Uint8Array; + Body?: Uint8Array; + uri?: string; +}; diff --git a/platforms/algorand/protocols/tokenBridge/src/utilities.ts b/platforms/algorand/protocols/tokenBridge/src/utilities.ts new file mode 100644 index 000000000..5c1497da8 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/utilities.ts @@ -0,0 +1,55 @@ +import { bytesToBigInt } from "algosdk"; + +export function safeBigIntToNumber(b: bigint): number { + if (b < BigInt(Number.MIN_SAFE_INTEGER) || b > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("Integer is unsafe"); + } + return Number(b); +} + +// TODO: Remove - VAA encoding/decoding can be done by the serialize/deserialize functions +export function extract3(buffer: Uint8Array, start: number, size: number) { + return buffer.slice(start, start + size); +} + +// export function uint8ArrayToNativeStringAlgorand(a: Uint8Array): string { +// return encodeAddress(a); +// } + +// export function hexToNativeStringAlgorand(s: string): string { +// return uint8ArrayToNativeStringAlgorand(hexToUint8Array(s)); +// } + +// export function nativeStringToHexAlgorand(s: string): string { +// return uint8ArrayToHex(decodeAddress(s).publicKey); +// } + +// TODO: If the string is a hex string, this can be replaced by bigname.decode() +export function hexToNativeAssetBigIntAlgorand(s: string): bigint { + return bytesToBigInt(hexToUint8Array(s)); +} + +// export function hexToNativeAssetStringAlgorand(s: string): string { +// return uint8ArrayToNativeStringAlgorand(hexToUint8Array(s)); +// } + +// hex.encode +export const uint8ArrayToHex = (a: Uint8Array): string => { + return Buffer.from(a).toString("hex"); +}; + +// hex.decode +export const hexToUint8Array = (h: string): Uint8Array => { + if (h.startsWith("0x")) h = h.slice(2); + return new Uint8Array(Buffer.from(h, "hex")); +}; + +// TODO +export function textToHexString(name: string): string { + return Buffer.from(name, "binary").toString("hex"); +} + +// TODO: bytes.encode +export function textToUint8Array(name: string): Uint8Array { + return new Uint8Array(Buffer.from(name, "binary")); +} diff --git a/platforms/algorand/protocols/tokenBridge/src/vaa.ts b/platforms/algorand/protocols/tokenBridge/src/vaa.ts new file mode 100644 index 000000000..f162764c5 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/src/vaa.ts @@ -0,0 +1,623 @@ +import { + CHAIN_ID_ALGORAND, + TransactionSignerPair, +} from "@wormhole-foundation/connect-sdk-algorand"; +import { + ABIMethod, + ABIType, + Algodv2, + LogicSigAccount, + OnApplicationComplete, + SuggestedParams, + Transaction, + bigIntToBytes, + encodeAddress, + getApplicationAddress, + makeApplicationCallTxnFromObject, + makeAssetTransferTxnWithSuggestedParamsFromObject, + makePaymentTxnWithSuggestedParamsFromObject, + signLogicSigTransaction, +} from "algosdk"; +import { ParsedVAA } from "./types"; +import { + ALGO_VERIFY, + ALGO_VERIFY_HASH, + MAX_BITS, + MAX_SIGS_PER_TXN, + ZERO_PAD_BYTES, +} from "./constants"; +import { + extract3, + hexToNativeAssetBigIntAlgorand, + hexToUint8Array, + safeBigIntToNumber, + textToHexString, + textToUint8Array, + uint8ArrayToHex, +} from "./utilities"; // TODO: Replace these to remove dependency on Buffer +import { assetOptinCheck, optIn } from "./assets"; +import { decodeLocalState } from "./apps"; +import { keccak256 } from "ethers"; + +class SubmitVAAState { + vaaMap: ParsedVAA; + accounts: string[]; + txs: TransactionSignerPair[]; + guardianAddr: string; + + constructor( + vaaMap: ParsedVAA, + accounts: string[], + txs: TransactionSignerPair[], + guardianAddr: string, + ) { + this.vaaMap = vaaMap; + this.accounts = accounts; + this.txs = txs; + this.guardianAddr = guardianAddr; + } +} + +/** + * Submits just the header of the VAA + * @param client AlgodV2 client + * @param bridgeId Application ID of the core b * ridge + * @param vaa The VAA (Just the header is used) + * @param senderAddr Sending account address + * @param appid Application ID + * @returns Promise with current VAA state + */ +export async function submitVAAHeader( + client: Algodv2, + bridgeId: bigint, + vaa: Uint8Array, + senderAddr: string, + appid: bigint, +): Promise { + // A lot of our logic here depends on parseVAA and knowing what the payload is + const parsedVAA = _parseVAAAlgorand(vaa); // TODO: Replace with deserialize() + const seq: bigint = parsedVAA.sequence / BigInt(MAX_BITS); + const chainRaw: string = parsedVAA.chainRaw; + const em: string = parsedVAA.emitter; + const index: number = parsedVAA.index; + + let txs: TransactionSignerPair[] = []; + // "seqAddr" + const { addr: seqAddr, txs: seqOptInTxs } = await optIn( + client, + senderAddr, + appid, + seq, + chainRaw + em, + ); + txs.push(...seqOptInTxs); + const guardianPgmName = textToHexString("guardian"); + // And then the signatures to help us verify the vaa_s + // "guardianAddr" + const { addr: guardianAddr, txs: guardianOptInTxs } = await optIn( + client, + senderAddr, + bridgeId, + BigInt(index), + guardianPgmName, + ); + txs.push(...guardianOptInTxs); + let accts: string[] = [seqAddr, guardianAddr]; + + // When we attest for a new token, we need some place to store the info... later we will need to + // mirror the other way as well + const keys: Uint8Array = await decodeLocalState(client, bridgeId, guardianAddr); + + const params: SuggestedParams = await client.getTransactionParams().do(); + + // We don't pass the entire payload in but instead just pass it pre-digested. This gets around size + // limitations with lsigs AND reduces the cost of the entire operation on a congested network by reducing the + // bytes passed into the transaction + // This is a 2 pass digest + const digest = keccak256(keccak256(parsedVAA.digest)).slice(2); + + // How many signatures can we process in a single txn... we can do 6! + // There are likely upwards of 19 signatures. So, we ned to split things up + const numSigs: number = parsedVAA.siglen; + let numTxns: number = Math.floor(numSigs / MAX_SIGS_PER_TXN) + 1; + + const SIG_LEN: number = 66; + const BSIZE: number = SIG_LEN * MAX_SIGS_PER_TXN; + const signatures: Uint8Array = parsedVAA.signatures; + const verifySigArg: Uint8Array = textToUint8Array("verifySigs"); + const lsa = new LogicSigAccount(ALGO_VERIFY); + for (let nt = 0; nt < numTxns; nt++) { + let sigs: Uint8Array = signatures.slice(nt * BSIZE); + if (sigs.length > BSIZE) { + sigs = sigs.slice(0, BSIZE); + } + + // The keyset is the set of guardians that correspond + // to the current set of signatures in this loop. + // Each signature in 20 bytes and comes from decodeLocalState() + const GuardianKeyLen: number = 20; + const numSigsThisTxn = sigs.length / SIG_LEN; + let arraySize: number = numSigsThisTxn * GuardianKeyLen; + let keySet: Uint8Array = new Uint8Array(arraySize); + for (let i = 0; i < numSigsThisTxn; i++) { + // The first byte of the sig is the relative index of that signature in the signatures array + // Use that index to get the appropriate guardian key + const idx = sigs[i * SIG_LEN]!; // Added non-null assertion + const key = keys.slice(idx * GuardianKeyLen + 1, (idx + 1) * GuardianKeyLen + 1); + keySet.set(key, i * 20); + } + + const appTxn = makeApplicationCallTxnFromObject({ + appArgs: [verifySigArg, sigs, keySet, hexToUint8Array(digest)], + accounts: accts, + appIndex: safeBigIntToNumber(bridgeId), + from: ALGO_VERIFY_HASH, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }); + appTxn.fee = 0; + txs.push({ + tx: appTxn, + signer: { + addr: lsa.address(), + signTxn: (txn: Transaction) => Promise.resolve(signLogicSigTransaction(txn, lsa).blob), + }, + }); + } + const appTxn = makeApplicationCallTxnFromObject({ + appArgs: [textToUint8Array("verifyVAA"), vaa], + accounts: accts, + appIndex: safeBigIntToNumber(bridgeId), + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }); + appTxn.fee = appTxn.fee * (2 + numTxns); // Was 1 + txs.push({ tx: appTxn, signer: null }); + + return new SubmitVAAState(parsedVAA, accts, txs, guardianAddr); +} + +/** + * Submits the VAA to the application + * @param client AlgodV2 client + * @param tokenBridgeId Application ID of the token bridge + * @param bridgeId Application ID of the core bridge + * @param vaa The VAA to be submitted + * @param senderAddr Sending account address + * @returns Promise with an array of TransactionSignerPair + */ +export async function _submitVAAAlgorand( + client: Algodv2, + tokenBridgeId: bigint, + bridgeId: bigint, + vaa: Uint8Array, + senderAddr: string, +): Promise { + let sstate = await submitVAAHeader(client, bridgeId, vaa, senderAddr, tokenBridgeId); + + let parsedVAA = sstate.vaaMap; + let accts = sstate.accounts; + let txs = sstate.txs; + + // If this happens to be setting up a new guardian set, we probably need it as well... + if ( + parsedVAA.Meta === "CoreGovernance" && + parsedVAA.action === 2 && + parsedVAA.NewGuardianSetIndex !== undefined + ) { + const ngsi = parsedVAA.NewGuardianSetIndex; + const guardianPgmName = textToHexString("guardian"); + // "newGuardianAddr" + const { addr: newGuardianAddr, txs: newGuardianOptInTxs } = await optIn( + client, + senderAddr, + bridgeId, + BigInt(ngsi), + guardianPgmName, + ); + accts.push(newGuardianAddr); + txs.unshift(...newGuardianOptInTxs); + } + + // When we attest for a new token, we need some place to store the info... later we will need to + // mirror the other way as well + const meta = parsedVAA.Meta; + let chainAddr: string = ""; + if ( + (meta === "TokenBridge Attest" || + meta === "TokenBridge Transfer" || + meta === "TokenBridge Transfer With Payload") && + parsedVAA.Contract !== undefined + ) { + if (parsedVAA.FromChain !== CHAIN_ID_ALGORAND && parsedVAA.FromChain) { + // "TokenBridge chainAddr" + const result = await optIn( + client, + senderAddr, + tokenBridgeId, + BigInt(parsedVAA.FromChain), + parsedVAA.Contract, + ); + chainAddr = result.addr; + txs.unshift(...result.txs); + } else { + const assetId = hexToNativeAssetBigIntAlgorand(parsedVAA.Contract); + // "TokenBridge native chainAddr" + const result = await optIn( + client, + senderAddr, + tokenBridgeId, + assetId, + textToHexString("native"), + ); + chainAddr = result.addr; + txs.unshift(...result.txs); + } + accts.push(chainAddr); + } + + const params: SuggestedParams = await client.getTransactionParams().do(); + + if (meta === "CoreGovernance") { + txs.push({ + tx: makeApplicationCallTxnFromObject({ + appArgs: [textToUint8Array("governance"), vaa], + accounts: accts, + appIndex: safeBigIntToNumber(bridgeId), + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + txs.push({ + tx: makeApplicationCallTxnFromObject({ + appArgs: [textToUint8Array("nop"), bigIntToBytes(5, 8)], + appIndex: safeBigIntToNumber(bridgeId), + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + } + if (meta === "TokenBridge RegisterChain" || meta === "TokenBridge UpgradeContract") { + txs.push({ + tx: makeApplicationCallTxnFromObject({ + appArgs: [textToUint8Array("governance"), vaa], + accounts: accts, + appIndex: safeBigIntToNumber(tokenBridgeId), + foreignApps: [safeBigIntToNumber(bridgeId)], + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + } + + if (meta === "TokenBridge Attest") { + let asset: Uint8Array = await decodeLocalState(client, tokenBridgeId, chainAddr); + let foreignAssets: number[] = []; + if (asset.length > 8) { + const tmp = Buffer.from(asset.slice(0, 8)); + foreignAssets.push(safeBigIntToNumber(tmp.readBigUInt64BE(0))); + } + txs.push({ + tx: makePaymentTxnWithSuggestedParamsFromObject({ + from: senderAddr, + to: chainAddr, + amount: 100000, + suggestedParams: params, + }), + signer: null, + }); + let buf: Uint8Array = new Uint8Array(1); + buf[0] = 0x01; + txs.push({ + tx: makeApplicationCallTxnFromObject({ + appArgs: [textToUint8Array("nop"), buf], + appIndex: safeBigIntToNumber(tokenBridgeId), + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + + buf = new Uint8Array(1); + buf[0] = 0x02; + txs.push({ + tx: makeApplicationCallTxnFromObject({ + appArgs: [textToUint8Array("nop"), buf], + appIndex: safeBigIntToNumber(tokenBridgeId), + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + + txs.push({ + tx: makeApplicationCallTxnFromObject({ + accounts: accts, + appArgs: [textToUint8Array("receiveAttest"), vaa], + appIndex: safeBigIntToNumber(tokenBridgeId), + foreignAssets: foreignAssets, + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + txs[txs.length - 1]!.tx.fee = txs[txs.length - 1]!.tx.fee * 2; // Added non-null assertion. QUESTIONBW: There are like 3 different ways of adjusting fees in various functions--this should be standardized + } + + if ( + (meta === "TokenBridge Transfer" || meta === "TokenBridge Transfer With Payload") && + parsedVAA.Contract !== undefined + ) { + let foreignAssets: number[] = []; + let a: number = 0; + if (parsedVAA.FromChain !== CHAIN_ID_ALGORAND) { + let asset = await decodeLocalState(client, tokenBridgeId, chainAddr); + + if (asset.length > 8) { + const tmp = Buffer.from(asset.slice(0, 8)); + a = safeBigIntToNumber(tmp.readBigUInt64BE(0)); + } + } else { + a = parseInt(parsedVAA.Contract, 16); + } + + // The receiver needs to be optin in to receive the coins... Yeah, the relayer pays for this + + let aid = 0; + let addr = ""; + + if (parsedVAA.ToAddress !== undefined) { + if (parsedVAA.ToChain === 8 && parsedVAA.Type === 3) { + aid = Number(hexToNativeAssetBigIntAlgorand(uint8ArrayToHex(parsedVAA.ToAddress))); + addr = getApplicationAddress(aid); + } else { + addr = encodeAddress(parsedVAA.ToAddress); + } + } + + if (a !== 0) { + foreignAssets.push(a); + if (!(await assetOptinCheck(client, BigInt(a), addr))) { + if (senderAddr != addr) { + throw new Error("cannot ASA optin for somebody else (asset " + a.toString() + ")"); + } + + txs.unshift({ + tx: makeAssetTransferTxnWithSuggestedParamsFromObject({ + amount: 0, + assetIndex: a, + from: senderAddr, + suggestedParams: params, + to: senderAddr, + }), + signer: null, + }); + } + } + accts.push(addr); + txs.push({ + tx: makeApplicationCallTxnFromObject({ + accounts: accts, + appArgs: [textToUint8Array("completeTransfer"), vaa], + appIndex: safeBigIntToNumber(tokenBridgeId), + foreignAssets: foreignAssets, + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + + // We need to cover the inner transactions + if ( + parsedVAA.Fee !== undefined && + Buffer.compare(parsedVAA.Fee, Buffer.from(ZERO_PAD_BYTES, "hex")) === 0 + ) + txs[txs.length - 1]!.tx.fee = txs[txs.length - 1]!.tx.fee * 2; // Added non-null assertions + else txs[txs.length - 1]!.tx.fee = txs[txs.length - 1]!.tx.fee * 3; + + if (meta === "TokenBridge Transfer With Payload") { + txs[txs.length - 1]!.tx.appForeignApps = [aid]; // Added non-null assertion + + let m = ABIMethod.fromSignature("portal_transfer(byte[])byte[]"); + + txs.push({ + tx: makeApplicationCallTxnFromObject({ + appArgs: [m.getSelector(), (m.args[0]!.type as ABIType).encode(vaa)], // Added non-null assertion + appIndex: aid, + foreignAssets: foreignAssets, + from: senderAddr, + onComplete: OnApplicationComplete.NoOpOC, + suggestedParams: params, + }), + signer: null, + }); + } + } + + return txs; +} + +/** + * Parses the VAA into a Map + * @param vaa The VAA to be parsed + * @returns The ParsedVAA containing the parsed elements of the VAA + */ +export function _parseVAAAlgorand(vaa: Uint8Array): ParsedVAA { + let ret = {} as ParsedVAA; + let buf = Buffer.from(vaa); + ret.version = buf.readIntBE(0, 1); + ret.index = buf.readIntBE(1, 4); + ret.siglen = buf.readIntBE(5, 1); + const siglen = ret.siglen; + if (siglen) { + ret.signatures = extract3(vaa, 6, siglen * 66); + } + const sigs: Uint8Array[] = []; + for (let i = 0; i < siglen; i++) { + const start = 6 + i * 66; + const len = 66; + const sigBuf = extract3(vaa, start, len); + sigs.push(sigBuf); + } + ret.sigs = sigs; + let off = siglen * 66 + 6; + ret.digest = vaa.slice(off); // This is what is actually signed... + ret.timestamp = buf.readIntBE(off, 4); + off += 4; + ret.nonce = buf.readIntBE(off, 4); + off += 4; + ret.chainRaw = Buffer.from(extract3(vaa, off, 2)).toString("hex"); + ret.chain = buf.readIntBE(off, 2); + off += 2; + ret.emitter = Buffer.from(extract3(vaa, off, 32)).toString("hex"); + off += 32; + ret.sequence = buf.readBigUInt64BE(off); + off += 8; + ret.consistency = buf.readIntBE(off, 1); + off += 1; + + ret.Meta = "Unknown"; + + if ( + !Buffer.compare( + extract3(buf, off, 32), + Buffer.from("000000000000000000000000000000000000000000546f6b656e427269646765", "hex"), + ) + ) { + ret.Meta = "TokenBridge"; + ret.module = extract3(vaa, off, 32); + off += 32; + ret.action = buf.readIntBE(off, 1); + off += 1; + if (ret.action === 1) { + ret.Meta = "TokenBridge RegisterChain"; + ret.targetChain = buf.readIntBE(off, 2); + off += 2; + ret.EmitterChainID = buf.readIntBE(off, 2); + off += 2; + ret.targetEmitter = extract3(vaa, off, 32); + off += 32; + } else if (ret.action === 2) { + ret.Meta = "TokenBridge UpgradeContract"; + ret.targetChain = buf.readIntBE(off, 2); + off += 2; + ret.newContract = extract3(vaa, off, 32); + off += 32; + } + } else if ( + !Buffer.compare( + extract3(buf, off, 32), + Buffer.from("00000000000000000000000000000000000000000000000000000000436f7265", "hex"), + ) + ) { + ret.Meta = "CoreGovernance"; + ret.module = extract3(vaa, off, 32); + off += 32; + ret.action = buf.readIntBE(off, 1); + off += 1; + ret.targetChain = buf.readIntBE(off, 2); + off += 2; + ret.NewGuardianSetIndex = buf.readIntBE(off, 4); + } + + // ret.len=vaa.slice(off).length) + // ret.act=buf.readIntBE(off, 1)) + + ret.Body = vaa.slice(off); + + if (vaa.slice(off).length === 100 && buf.readIntBE(off, 1) === 2) { + ret.Meta = "TokenBridge Attest"; + ret.Type = buf.readIntBE(off, 1); + off += 1; + ret.Contract = uint8ArrayToHex(extract3(vaa, off, 32)); + off += 32; + ret.FromChain = buf.readIntBE(off, 2); + off += 2; + ret.Decimals = buf.readIntBE(off, 1); + off += 1; + ret.Symbol = extract3(vaa, off, 32); + off += 32; + ret.Name = extract3(vaa, off, 32); + } + + if (vaa.slice(off).length === 133 && buf.readIntBE(off, 1) === 1) { + ret.Meta = "TokenBridge Transfer"; + ret.Type = buf.readIntBE(off, 1); + off += 1; + ret.Amount = extract3(vaa, off, 32); + off += 32; + ret.Contract = uint8ArrayToHex(extract3(vaa, off, 32)); + off += 32; + ret.FromChain = buf.readIntBE(off, 2); + off += 2; + ret.ToAddress = extract3(vaa, off, 32); + off += 32; + ret.ToChain = buf.readIntBE(off, 2); + off += 2; + ret.Fee = extract3(vaa, off, 32); + } + + if (off >= buf.length) { + return ret; + } + if (buf.readIntBE(off, 1) === 3) { + ret.Meta = "TokenBridge Transfer With Payload"; + ret.Type = buf.readIntBE(off, 1); + off += 1; + ret.Amount = extract3(vaa, off, 32); + off += 32; + ret.Contract = uint8ArrayToHex(extract3(vaa, off, 32)); + off += 32; + ret.FromChain = buf.readIntBE(off, 2); + off += 2; + ret.ToAddress = extract3(vaa, off, 32); + off += 32; + ret.ToChain = buf.readIntBE(off, 2); + off += 2; + ret.FromAddress = extract3(vaa, off, 32); + off += 32; + ret.Payload = vaa.slice(off); + } + + console.log("Parsed VAA: ", ret); + return ret; +} + +// QUESTIONBW: Can this be removed entirely? +/** + * Parses the VAA into a Map + * @param vaa The VAA to be parsed + * @returns The ParsedVAA containing the parsed elements of the VAA + */ +// export function _parseNFTAlgorand(vaa: Uint8Array): ParsedVAA { +// let ret = _parseVAAAlgorand(vaa); + +// let arr = Buffer.from(ret.Body as Uint8Array); + +// ret.action = arr.readUInt8(0); +// ret.Contract = arr.slice(1, 1 + 32).toString('hex'); +// ret.FromChain = arr.readUInt16BE(33); +// ret.Symbol = Buffer.from(arr.slice(35, 35 + 32)); +// ret.Name = Buffer.from(arr.slice(67, 67 + 32)); +// ret.TokenId = arr.slice(99, 99 + 32); +// let uri_len = arr.readUInt8(131); +// ret.uri = Buffer.from(arr.slice(132, 132 + uri_len)) +// .toString('utf8') +// .replace(METADATA_REPLACE, ''); +// let target_offset = 132 + uri_len; +// ret.ToAddress = arr.slice(target_offset, target_offset + 32); +// ret.ToChain = arr.readUInt16BE(target_offset + 32); + +// return ret; +// } diff --git a/platforms/algorand/protocols/tokenBridge/tsconfig.cjs.json b/platforms/algorand/protocols/tokenBridge/tsconfig.cjs.json new file mode 100644 index 000000000..73a0e681f --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.cjs.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist/cjs", + "rootDir": "src" + } +} diff --git a/platforms/algorand/protocols/tokenBridge/tsconfig.esm.json b/platforms/algorand/protocols/tokenBridge/tsconfig.esm.json new file mode 100644 index 000000000..a9c110d37 --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.esm.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist/esm", + "rootDir": "src" + } +} diff --git a/platforms/algorand/protocols/tokenBridge/typedoc.json b/platforms/algorand/protocols/tokenBridge/typedoc.json new file mode 100644 index 000000000..f2fbd427c --- /dev/null +++ b/platforms/algorand/protocols/tokenBridge/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + } \ No newline at end of file diff --git a/platforms/algorand/src/address.ts b/platforms/algorand/src/address.ts new file mode 100644 index 000000000..0b1a75808 --- /dev/null +++ b/platforms/algorand/src/address.ts @@ -0,0 +1,76 @@ +import { + Address, + Platform, + UniversalAddress, + encoding, + registerNative, +} from "@wormhole-foundation/connect-sdk"; + +import { AlgorandPlatform } from "./platform"; +import { _platform, AnyAlgorandAddress } from "./types"; +import { decodeAddress, encodeAddress, isValidAddress } from "algosdk"; + +export const AlgorandZeroAddress = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ"; + +export class AlgorandAddress implements Address { + static readonly byteSize = 32; + static readonly platform: Platform = _platform; + // stored as checksum address + private readonly address: string; + + constructor(address: AnyAlgorandAddress) { + if (AlgorandAddress.instanceof(address)) { + const a = address as unknown as AlgorandAddress; + this.address = a.address; + } else if (UniversalAddress.instanceof(address)) { + this.address = encodeAddress(address.unwrap()); + } else if (typeof address === "string") { + this.address = address; + } else if (address instanceof Uint8Array && address.byteLength === AlgorandAddress.byteSize) { + this.address = encodeAddress(address); + } else if (address instanceof Uint8Array && address.byteLength === 8) { + // ASA IDs are 8 bytes; this is padded to 32 bytes like addresses + this.address = encodeAddress(encoding.bytes.zpad(address, 32)); + } else throw new Error(`Invalid Algorand address or ASA ID: ${address}`); + } + + unwrap(): string { + return this.address; + } + toString() { + return this.address; + } + toNative() { + return this; + } + toUint8Array() { + return decodeAddress(this.address).publicKey; + } + toUniversalAddress() { + return new UniversalAddress(this.toUint8Array()); + } + static isValidAddress(address: string) { + return isValidAddress(address); + } + static instanceof(address: any): address is AlgorandAddress { + return address.platform === AlgorandPlatform._platform; + } + equals(other: AlgorandAddress | UniversalAddress): boolean { + if (AlgorandAddress.instanceof(other)) { + return other.address === this.address; + } else { + return other.equals(this.toUniversalAddress()); + } + } +} + +declare global { + namespace Wormhole { + interface PlatformToNativeAddressMapping { + // @ts-ignore + Algorand: AlgorandAddress; + } + } +} + +registerNative(_platform, AlgorandAddress); diff --git a/platforms/algorand/src/chain.ts b/platforms/algorand/src/chain.ts new file mode 100644 index 000000000..79047384e --- /dev/null +++ b/platforms/algorand/src/chain.ts @@ -0,0 +1,7 @@ +import { Chain, ChainContext, Network } from "@wormhole-foundation/connect-sdk"; +import { AlgorandChains, AlgorandPlatformType } from "./types"; + +export class AlgorandChain< + N extends Network = Network, + C extends Chain = AlgorandChains, +> extends ChainContext {} diff --git a/platforms/algorand/src/constants.ts b/platforms/algorand/src/constants.ts new file mode 100644 index 000000000..a4c2fd58a --- /dev/null +++ b/platforms/algorand/src/constants.ts @@ -0,0 +1,17 @@ +import { Chain, Network, RoArray, constMap } from "@wormhole-foundation/connect-sdk"; + +export const CHAIN_NAME_ALGORAND = "Algorand"; +export const CHAIN_ID_ALGORAND = 8; + +const networkChainAlgorandGenesisHashes = [ + ["Mainnet", [["Algorand", "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8="]]], + ["Testnet", [["Algorand", "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI="]]], // Note: this is referred to as `devnet` on Solana + ["Devnet", [["Algorand", ""]]], // Note: this is only for local testing with Tilt QUESTIONBW: Is there a deterministic genesis hash for the localnet Tilt creates? +] as const satisfies RoArray]>; + +export const algorandGenesisHashToNetworkChainPair = constMap(networkChainAlgorandGenesisHashes, [ + 2, + [0, 1], +]); + +export const algorandNetworkChainToGenesisHash = constMap(networkChainAlgorandGenesisHashes); diff --git a/platforms/algorand/src/index.ts b/platforms/algorand/src/index.ts new file mode 100644 index 000000000..60502d0c8 --- /dev/null +++ b/platforms/algorand/src/index.ts @@ -0,0 +1,8 @@ +export * from "./address"; +export * from "./chain"; +export * from "./constants"; +export * from "./platform"; +export * from "./types"; +export * from "./unsignedTransaction"; + +export * as testing from "./testing"; diff --git a/platforms/algorand/src/platform.ts b/platforms/algorand/src/platform.ts new file mode 100644 index 000000000..4899a2cc6 --- /dev/null +++ b/platforms/algorand/src/platform.ts @@ -0,0 +1,199 @@ +import { + Balances, + Chain, + ChainsConfig, + Network, + PlatformContext, + SignedTx, + TokenId, + TxHash, + Wormhole, + chainToPlatform, + decimals, + nativeChainIds, + networkPlatformConfigs, +} from "@wormhole-foundation/connect-sdk"; +import { + Algodv2, + SignedTransaction, + bytesToBigInt, + decodeSignedTransaction, + waitForConfirmation, + modelsv2, +} from "algosdk"; +import { AlgorandChain } from "./chain"; +import { AlgorandChains, AlgorandPlatformType, AnyAlgorandAddress, _platform } from "./types"; +import { AlgorandAddress, AlgorandZeroAddress } from "./address"; + +/** + * @category Algorand + */ +export class AlgorandPlatform extends PlatformContext { + static _platform = _platform; + + constructor(network: N, _config?: ChainsConfig) { + super(network, _config ?? networkPlatformConfigs(network, AlgorandPlatform._platform)); + } + + getRpc(chain: C): Algodv2 { + if (chain in this.config) return new Algodv2("", this.config[chain]!.rpc); + throw new Error("No configuration available for chain: " + chain); + } + + getChain(chain: C): AlgorandChain { + if (chain in this.config) return new AlgorandChain(chain, this); + throw new Error("No configuration available for chain: " + chain); + } + + static nativeTokenId( + network: N, + chain: C, + ): TokenId { + if (!AlgorandPlatform.isSupportedChain(chain)) + throw new Error(`invalid chain for ${_platform}: ${chain}`); + return Wormhole.chainAddress(chain, AlgorandZeroAddress); + } + + static isNativeTokenId( + network: N, + chain: C, + tokenId: TokenId, + ): boolean { + if (!AlgorandPlatform.isSupportedChain(chain)) return false; + if (tokenId.chain !== chain) return false; + const native = this.nativeTokenId(network, chain); + return native == tokenId; + } + + static isSupportedChain(chain: Chain): boolean { + const platform = chainToPlatform(chain); + return platform === AlgorandPlatform._platform; + } + + static anyAlgorandAddressToAsaId(address: AnyAlgorandAddress): number { + const addr = new AlgorandAddress(address.toString()); + const lastEightBytes = addr.toUint8Array().slice(-8); + const asaId = Number(bytesToBigInt(lastEightBytes)); + return asaId; + } + + static async getDecimals( + chain: Chain, + rpc: Algodv2, + token: AnyAlgorandAddress | "native", + ): Promise { + if (token === "native") return BigInt(decimals.nativeDecimals(AlgorandPlatform._platform)); + const asaId = this.anyAlgorandAddressToAsaId(token); + const assetResp = await rpc.getAssetByID(asaId).do(); + const asset = modelsv2.Asset.from_obj_for_encoding(assetResp); + if (!asset.params || !asset.params.decimals) throw new Error("Could not fetch token details"); + return BigInt(asset.params.decimals); + } + + static async getBalance( + chain: Chain, + rpc: Algodv2, + walletAddr: string, + token: AnyAlgorandAddress | "native", + ): Promise { + if (token === "native") { + const resp = await rpc.accountInformation(walletAddr).do(); + const accountInfo = modelsv2.Account.from_obj_for_encoding(resp); + return BigInt(accountInfo.amount); + } + const asaId = this.anyAlgorandAddressToAsaId(token); + const acctAssetInfoResp = await rpc.accountAssetInformation(walletAddr, asaId).do(); + const accountAssetInfo = modelsv2.AssetHolding.from_obj_for_encoding(acctAssetInfoResp); + return BigInt(accountAssetInfo.amount); + } + + static async getBalances( + chain: Chain, + rpc: Algodv2, + walletAddr: string, + tokens: (AnyAlgorandAddress | "native")[], + ): Promise { + let native: bigint; + if (tokens.includes("native")) { + const acctInfoResp = await rpc.accountInformation(walletAddr).do(); + const accountInfo = modelsv2.Account.from_obj_for_encoding(acctInfoResp); + native = BigInt(accountInfo.amount); + } + const balancesArr = tokens.map(async (token) => { + if (token === "native") { + return { ["native"]: native }; + } + const asaId = this.anyAlgorandAddressToAsaId(token); + const acctAssetInfoResp = await rpc.accountAssetInformation(walletAddr, asaId).do(); + const accountAssetInfo = modelsv2.AssetHolding.from_obj_for_encoding(acctAssetInfoResp); + return BigInt(accountAssetInfo.amount); + }); + + return balancesArr.reduce((obj, item) => Object.assign(obj, item), {}); + } + + static async sendWait(chain: Chain, rpc: Algodv2, stxns: SignedTx[]): Promise { + const rounds = 4; + + const decodedStxns: SignedTransaction[] = stxns.map((val, idx) => { + const decodedStxn: SignedTransaction = decodeSignedTransaction(val); + return decodedStxn; + }); + + const txIds: string[] = decodedStxns.map((val, idx) => { + const id: string = val.txn.txID(); + return id; + }); + + const { txId } = await rpc.sendRawTransaction(stxns).do(); + if (!txId) { + throw new Error("Transaction(s) failed to send"); + } + const confirmResp = await waitForConfirmation(rpc, txId, rounds); + const ptr = modelsv2.PendingTransactionResponse.from_obj_for_encoding(confirmResp); + if (!ptr.confirmedRound) { + throw new Error(`Transaction(s) could not be confirmed in ${rounds} rounds`); + } + + console.log("txIds: ", txIds); + return txIds; + } + + static async getLatestBlock(rpc: Algodv2): Promise { + const statusResp = await rpc.status().do(); + const status = modelsv2.NodeStatusResponse.from_obj_for_encoding(statusResp); + if (!status.lastRound) { + throw new Error("Error getting status from node"); + } + return Number(status.lastRound); + } + + static async getLatestFinalizedBlock(rpc: Algodv2): Promise { + const statusResp = await rpc.status().do(); + const status = modelsv2.NodeStatusResponse.from_obj_for_encoding(statusResp); + if (!status.lastRound) { + throw new Error("Error getting status from node"); + } + return Number(status.lastRound); + } + + static chainFromChainId(genesisId: string): [Network, AlgorandChains] { + const networkChainPair = nativeChainIds.platformNativeChainIdToNetworkChain( + AlgorandPlatform._platform, + // @ts-ignore + genesisId, + ); + + if (networkChainPair === undefined) throw new Error(`Unknown native chain id ${genesisId}`); + + const [network, chain] = networkChainPair; + return [network, chain]; + } + + static async chainFromRpc(rpc: Algodv2): Promise<[Network, AlgorandChains]> { + const versionResp = await rpc.versionsCheck().do(); + const version = modelsv2.Version.from_obj_for_encoding(versionResp); + // const genesisHash = Buffer.from(version.genesisHashB64).toString("base64"); + return this.chainFromChainId(version.genesisId); + } +} diff --git a/platforms/algorand/src/testing/index.ts b/platforms/algorand/src/testing/index.ts new file mode 100644 index 000000000..5282af685 --- /dev/null +++ b/platforms/algorand/src/testing/index.ts @@ -0,0 +1 @@ +export * from "./signer"; diff --git a/platforms/algorand/src/testing/signer.ts b/platforms/algorand/src/testing/signer.ts new file mode 100644 index 000000000..3527c7678 --- /dev/null +++ b/platforms/algorand/src/testing/signer.ts @@ -0,0 +1,75 @@ +import { + Network, + SignOnlySigner, + SignedTx, + Signer, + UnsignedTransaction, +} from "@wormhole-foundation/connect-sdk"; +import { Account, Algodv2, assignGroupID, mnemonicToSecretKey } from "algosdk"; +import { AlgorandChains } from "../types"; +import { AlgorandPlatform } from "../platform"; + +export async function getAlgorandSigner( + rpc: Algodv2, + mnemonic: string, // 25-word Algorand mnemonic +): Promise { + const [network, chain] = await AlgorandPlatform.chainFromRpc(rpc); + return new AlgorandSigner(chain, rpc, mnemonic); +} + +// AlgorandSigner implements SignOnlySender +export class AlgorandSigner + implements SignOnlySigner +{ + _account: Account; + constructor( + private _chain: C, + _rpc: Algodv2, + mnemonic: string, + ) { + this._account = mnemonicToSecretKey(mnemonic); + } + + chain(): C { + return this._chain; + } + + address(): string { + return this._account.addr; + } + + async sign(tx: UnsignedTransaction[]): Promise { + const signed: Uint8Array[] = []; + const ungrouped = tx.map((val, idx) => { + return val.transaction.tx; + }); + const grouped = assignGroupID(ungrouped); + + // Replace the ungrouped Transactions with grouped Transactions + const groupedAlgoUnsignedTxns = tx.map((val, idx) => { + val.transaction.tx = grouped[idx]; + return val; + }); + + for (const algoUnsignedTxn of groupedAlgoUnsignedTxns) { + const { description, transaction: tsp } = algoUnsignedTxn; + const { tx, signer } = tsp; + + if (signer) { + console.log( + `Signing: ${description} transaction ${tx._getDictForDisplay()} with signer ${ + signer.addr + } for address ${this.address()}`, + ); + signed.push(await signer.signTxn(tx)); + } else { + console.log( + `Signing: ${description} transaction ${tx._getDictForDisplay()} with signer ${this.address()} for address ${this.address()}`, + ); + signed.push(tx.signTxn(this._account.sk)); + } + } + + return signed; + } +} diff --git a/platforms/algorand/src/types.ts b/platforms/algorand/src/types.ts new file mode 100644 index 000000000..6f42a581f --- /dev/null +++ b/platforms/algorand/src/types.ts @@ -0,0 +1,19 @@ +import { PlatformToChains, UniversalOrNative } from "@wormhole-foundation/connect-sdk"; +import { Transaction } from "algosdk"; + +export const _platform: "Algorand" = "Algorand"; +export type AlgorandPlatformType = typeof _platform; + +export type AlgorandChains = PlatformToChains; +export type UniversalOrAlgorand = UniversalOrNative; +export type AnyAlgorandAddress = UniversalOrAlgorand | string | Uint8Array; + +export type Signer = { + addr: string; + signTxn(txn: Transaction): Promise; +}; + +export type TransactionSignerPair = { + tx: Transaction; + signer: Signer | null; +}; diff --git a/platforms/algorand/src/unsignedTransaction.ts b/platforms/algorand/src/unsignedTransaction.ts new file mode 100644 index 000000000..5322b04d7 --- /dev/null +++ b/platforms/algorand/src/unsignedTransaction.ts @@ -0,0 +1,14 @@ +import { Network, UnsignedTransaction } from "@wormhole-foundation/connect-sdk"; +import { AlgorandChains, TransactionSignerPair } from "./types"; + +export class AlgorandUnsignedTransaction + implements UnsignedTransaction +{ + constructor( + readonly transaction: TransactionSignerPair, + readonly network: N, + readonly chain: C, + readonly description: string, + readonly parallelizable: boolean = false, + ) {} +} diff --git a/platforms/algorand/tsconfig.cjs.json b/platforms/algorand/tsconfig.cjs.json new file mode 100644 index 000000000..b5ef75178 --- /dev/null +++ b/platforms/algorand/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.cjs.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist/cjs", + "rootDir": "src" + } +} diff --git a/platforms/algorand/tsconfig.esm.json b/platforms/algorand/tsconfig.esm.json new file mode 100644 index 000000000..84bbdce2d --- /dev/null +++ b/platforms/algorand/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.esm.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist/esm", + "rootDir": "src" + } +} diff --git a/platforms/algorand/typedoc.json b/platforms/algorand/typedoc.json new file mode 100644 index 000000000..e12797d62 --- /dev/null +++ b/platforms/algorand/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + } \ No newline at end of file