From ed261b16d8d87746922ee88bd1e18c5be4ff2ea3 Mon Sep 17 00:00:00 2001 From: A5 Pickle <5342825+a5-pickle@users.noreply.github.com> Date: Fri, 12 Apr 2024 09:23:58 -0500 Subject: [PATCH] solana: auction participant example processes (#87) Co-authored-by: gator-boi Co-authored-by: A5 Pickle --- evm/env/testnet/arbitrum_sepolia.env | 12 +- evm/env/testnet/avalanche.env | 17 +- evm/env/testnet/base_sepolia.env | 12 +- evm/env/testnet/optimism_sepolia.env | 12 +- evm/env/testnet/polygon.env | 12 +- evm/env/testnet/sepolia.env | 12 +- evm/forge/scripts/TestTransfer.s.sol | 6 +- evm/forge/scripts/UpgradeTokenRouter.s.sol | 1 - solana/.gitignore | 2 + solana/README.md | 129 +++--- solana/cfg/testnet/sample.config.json | 96 ++++ solana/package-lock.json | 210 +++++++++ solana/package.json | 2 + .../containers/CachedBlockhash.ts | 70 +++ .../containers/OfferToken.ts | 62 +++ .../auction-participant/containers/index.ts | 2 + .../auction-participant/executeOrder/app.ts | 202 +++++++++ .../auction-participant/improveOffer/app.ts | 167 +++++++ solana/ts/auction-participant/utils/config.ts | 426 ++++++++++++++++++ .../ts/auction-participant/utils/evm/index.ts | 1 + .../utils/evm/wormholeCctp.ts | 95 ++++ solana/ts/auction-participant/utils/index.ts | 56 +++ solana/ts/auction-participant/utils/logger.ts | 25 + .../utils/placeInitialOffer.ts | 162 +++++++ .../utils/preparePostVaaTx.ts | 80 ++++ solana/ts/auction-participant/utils/sendTx.ts | 164 +++++++ .../utils/settleAuction.ts | 249 ++++++++++ .../ts/auction-participant/utils/wormscan.ts | 50 ++ .../vaaAuctionRelayer/app.ts | 137 ++++++ solana/ts/enactNewTestnetAuctionConfig.ts | 88 ++++ solana/ts/scripts/executeFastOrder.ts | 111 +++++ solana/ts/scripts/getTestnetInfo.ts | 61 +-- .../AuctionParticipant.ts | 115 ----- .../improveAuctionOfferAndExecute/app.ts | 39 -- .../ts/scripts/setUpTestnetMatchingEngine.ts | 2 +- solana/ts/src/matchingEngine/index.ts | 20 +- .../state/PreparedOrderResponse.ts | 1 - solana/ts/tests/01__matchingEngine.ts | 1 + 38 files changed, 2622 insertions(+), 287 deletions(-) create mode 100644 solana/cfg/testnet/sample.config.json create mode 100644 solana/ts/auction-participant/containers/CachedBlockhash.ts create mode 100644 solana/ts/auction-participant/containers/OfferToken.ts create mode 100644 solana/ts/auction-participant/containers/index.ts create mode 100644 solana/ts/auction-participant/executeOrder/app.ts create mode 100644 solana/ts/auction-participant/improveOffer/app.ts create mode 100644 solana/ts/auction-participant/utils/config.ts create mode 100644 solana/ts/auction-participant/utils/evm/index.ts create mode 100644 solana/ts/auction-participant/utils/evm/wormholeCctp.ts create mode 100644 solana/ts/auction-participant/utils/index.ts create mode 100644 solana/ts/auction-participant/utils/logger.ts create mode 100644 solana/ts/auction-participant/utils/placeInitialOffer.ts create mode 100644 solana/ts/auction-participant/utils/preparePostVaaTx.ts create mode 100644 solana/ts/auction-participant/utils/sendTx.ts create mode 100644 solana/ts/auction-participant/utils/settleAuction.ts create mode 100644 solana/ts/auction-participant/utils/wormscan.ts create mode 100644 solana/ts/auction-participant/vaaAuctionRelayer/app.ts create mode 100644 solana/ts/enactNewTestnetAuctionConfig.ts create mode 100644 solana/ts/scripts/executeFastOrder.ts delete mode 100644 solana/ts/scripts/improveAuctionOfferAndExecute/AuctionParticipant.ts delete mode 100644 solana/ts/scripts/improveAuctionOfferAndExecute/app.ts diff --git a/evm/env/testnet/arbitrum_sepolia.env b/evm/env/testnet/arbitrum_sepolia.env index 22623aa..3892062 100644 --- a/evm/env/testnet/arbitrum_sepolia.env +++ b/evm/env/testnet/arbitrum_sepolia.env @@ -37,17 +37,17 @@ export RELEASE_OWNER_ASSISTANT_ADDRESS=0x ### Token Router Proxy (evm address) -export RELEASE_TOKEN_ROUTER_ADDRESS=0x +export RELEASE_TOKEN_ROUTER_ADDRESS=0xc1Cf3501ef0b26c8A47759F738832563C7cB014A ############################### Matching Engine ############################### ### Matching Engine Proxy (universal evm address) -export RELEASE_MATCHING_ENGINE_CHAIN=6 -export RELEASE_MATCHING_ENGINE_ADDRESS=0x -export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x -export RELEASE_MATCHING_ENGINE_DOMAIN=1 +export RELEASE_MATCHING_ENGINE_CHAIN=1 +export RELEASE_MATCHING_ENGINE_ADDRESS=0x3e374fcd3aaf2ed067f3c93d21416855ec7916cfd2c2127bcbc68b3b1fb73077 +export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x58b82fca98f022ca29cfe822d1a3f92930020970910f0a99a0d9b995b21f99d5 +export RELEASE_MATCHING_ENGINE_DOMAIN=5 ############################## Test Params ############################### @@ -57,6 +57,6 @@ export TEST_AMOUNT_IN=500000000 export TEST_TARGET_CHAIN= export TEST_REDEEMER= export TEST_IS_FAST=true -export TEST_BASE_AUCTION_PRICE= +export TEST_FEE= export TEST_DEADLINE=0 diff --git a/evm/env/testnet/avalanche.env b/evm/env/testnet/avalanche.env index 6331894..76924f8 100644 --- a/evm/env/testnet/avalanche.env +++ b/evm/env/testnet/avalanche.env @@ -41,16 +41,16 @@ export RELEASE_FEE_RECIPIENT_ADDRESS=0x ############################### Token Router ################################# ### Token Router Proxy (evm address) -export RELEASE_TOKEN_ROUTER_ADDRESS=0x +export RELEASE_TOKEN_ROUTER_ADDRESS=0x7353B29FDc79435dcC7ECc9Ac9F9b61d83B4E0F4 ############################### Matching Engine ############################### ### Matching Engine Proxy (universal evm address) -export RELEASE_MATCHING_ENGINE_CHAIN=6 -export RELEASE_MATCHING_ENGINE_ADDRESS=0x -export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x -export RELEASE_MATCHING_ENGINE_DOMAIN=1 +export RELEASE_MATCHING_ENGINE_CHAIN=1 +export RELEASE_MATCHING_ENGINE_ADDRESS=0x3e374fcd3aaf2ed067f3c93d21416855ec7916cfd2c2127bcbc68b3b1fb73077 +export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x58b82fca98f022ca29cfe822d1a3f92930020970910f0a99a0d9b995b21f99d5 +export RELEASE_MATCHING_ENGINE_DOMAIN=5 ### Auction Parameters export RELEASE_USER_REWARD_BPS=250000 @@ -62,11 +62,10 @@ export RELEASE_PENALTY_BLOCKS=20 ############################## Test Params ############################### - -export TEST_AMOUNT_IN=500000000 -export TEST_TARGET_CHAIN= +export TEST_AMOUNT_IN=10000000 +export TEST_TARGET_CHAIN=2 export TEST_REDEEMER= export TEST_IS_FAST=true -export TEST_BASE_AUCTION_PRICE= +export TEST_FEE=2000000 export TEST_DEADLINE=0 diff --git a/evm/env/testnet/base_sepolia.env b/evm/env/testnet/base_sepolia.env index 4bf31cd..2e21847 100644 --- a/evm/env/testnet/base_sepolia.env +++ b/evm/env/testnet/base_sepolia.env @@ -37,17 +37,17 @@ export RELEASE_OWNER_ASSISTANT_ADDRESS=0x ### Token Router Proxy (evm address) -export RELEASE_TOKEN_ROUTER_ADDRESS=0x +export RELEASE_TOKEN_ROUTER_ADDRESS=0x4452B708C01d6aD7058a7541A3A82f0aD0A1abB1 ############################### Matching Engine ############################### ### Matching Engine Proxy (universal evm address) -export RELEASE_MATCHING_ENGINE_CHAIN=6 -export RELEASE_MATCHING_ENGINE_ADDRESS=0x -export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x -export RELEASE_MATCHING_ENGINE_DOMAIN=1 +export RELEASE_MATCHING_ENGINE_CHAIN=1 +export RELEASE_MATCHING_ENGINE_ADDRESS=0x3e374fcd3aaf2ed067f3c93d21416855ec7916cfd2c2127bcbc68b3b1fb73077 +export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x58b82fca98f022ca29cfe822d1a3f92930020970910f0a99a0d9b995b21f99d5 +export RELEASE_MATCHING_ENGINE_DOMAIN=5 ############################## Test Params ############################### @@ -57,5 +57,5 @@ export TEST_AMOUNT_IN=500000000 export TEST_TARGET_CHAIN= export TEST_REDEEMER= export TEST_IS_FAST=true -export TEST_BASE_AUCTION_PRICE= +export TEST_FEE= export TEST_DEADLINE=0 diff --git a/evm/env/testnet/optimism_sepolia.env b/evm/env/testnet/optimism_sepolia.env index 7e93a07..e976215 100644 --- a/evm/env/testnet/optimism_sepolia.env +++ b/evm/env/testnet/optimism_sepolia.env @@ -37,17 +37,17 @@ export RELEASE_OWNER_ASSISTANT_ADDRESS=0x ### Token Router Proxy (evm address) -export RELEASE_TOKEN_ROUTER_ADDRESS=0x +export RELEASE_TOKEN_ROUTER_ADDRESS=0xc1Cf3501ef0b26c8A47759F738832563C7cB014A ############################### Matching Engine ############################### ### Matching Engine Proxy (universal evm address) -export RELEASE_MATCHING_ENGINE_CHAIN=6 -export RELEASE_MATCHING_ENGINE_ADDRESS=0x -export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x -export RELEASE_MATCHING_ENGINE_DOMAIN=1 +export RELEASE_MATCHING_ENGINE_CHAIN=1 +export RELEASE_MATCHING_ENGINE_ADDRESS=0x3e374fcd3aaf2ed067f3c93d21416855ec7916cfd2c2127bcbc68b3b1fb73077 +export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x58b82fca98f022ca29cfe822d1a3f92930020970910f0a99a0d9b995b21f99d5 +export RELEASE_MATCHING_ENGINE_DOMAIN=5 ############################## Test Params ############################### @@ -57,5 +57,5 @@ export TEST_AMOUNT_IN=500000000 export TEST_TARGET_CHAIN= export TEST_REDEEMER= export TEST_IS_FAST=true -export TEST_BASE_AUCTION_PRICE= +export TEST_FEE= export TEST_DEADLINE=0 diff --git a/evm/env/testnet/polygon.env b/evm/env/testnet/polygon.env index 47db16a..733e506 100644 --- a/evm/env/testnet/polygon.env +++ b/evm/env/testnet/polygon.env @@ -37,17 +37,17 @@ export RELEASE_OWNER_ASSISTANT_ADDRESS=0x ### Token Router Proxy (evm address) -export RELEASE_TOKEN_ROUTER_ADDRESS=0x +export RELEASE_TOKEN_ROUTER_ADDRESS=0x3Ce8a3aC230Eb4bCE3688f2A1ab21d986a0A0B06 ############################### Matching Engine ############################### ### Matching Engine Proxy (universal evm address) -export RELEASE_MATCHING_ENGINE_CHAIN=6 -export RELEASE_MATCHING_ENGINE_ADDRESS=0x -export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x -export RELEASE_MATCHING_ENGINE_DOMAIN=1 +export RELEASE_MATCHING_ENGINE_CHAIN=1 +export RELEASE_MATCHING_ENGINE_ADDRESS=0x3e374fcd3aaf2ed067f3c93d21416855ec7916cfd2c2127bcbc68b3b1fb73077 +export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x58b82fca98f022ca29cfe822d1a3f92930020970910f0a99a0d9b995b21f99d5 +export RELEASE_MATCHING_ENGINE_DOMAIN=5 ############################## Test Params ############################### @@ -57,5 +57,5 @@ export TEST_AMOUNT_IN=500000000 export TEST_TARGET_CHAIN= export TEST_REDEEMER= export TEST_IS_FAST=true -export TEST_BASE_AUCTION_PRICE= +export TEST_FEE= export TEST_DEADLINE=0 diff --git a/evm/env/testnet/sepolia.env b/evm/env/testnet/sepolia.env index 18603b7..3f760e5 100644 --- a/evm/env/testnet/sepolia.env +++ b/evm/env/testnet/sepolia.env @@ -38,17 +38,17 @@ export RELEASE_OWNER_ASSISTANT_ADDRESS=0x ### Token Router Proxy (evm address) -export RELEASE_TOKEN_ROUTER_ADDRESS=0x +export RELEASE_TOKEN_ROUTER_ADDRESS=0x603541d1Cf7178C407aA7369b67CB7e0274952e2 ############################### Matching Engine ############################### ### Matching Engine Proxy (universal evm address) -export RELEASE_MATCHING_ENGINE_CHAIN=6 -export RELEASE_MATCHING_ENGINE_ADDRESS=0x -export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x -export RELEASE_MATCHING_ENGINE_DOMAIN=1 +export RELEASE_MATCHING_ENGINE_CHAIN=1 +export RELEASE_MATCHING_ENGINE_ADDRESS=0x3e374fcd3aaf2ed067f3c93d21416855ec7916cfd2c2127bcbc68b3b1fb73077 +export RELEASE_MATCHING_ENGINE_MINT_RECIPIENT=0x58b82fca98f022ca29cfe822d1a3f92930020970910f0a99a0d9b995b21f99d5 +export RELEASE_MATCHING_ENGINE_DOMAIN=5 ############################## Test Params ############################### @@ -58,5 +58,5 @@ export TEST_AMOUNT_IN=500000000 export TEST_TARGET_CHAIN= export TEST_REDEEMER= export TEST_IS_FAST=true -export TEST_BASE_AUCTION_PRICE= +export TEST_FEE= export TEST_DEADLINE=0 diff --git a/evm/forge/scripts/TestTransfer.s.sol b/evm/forge/scripts/TestTransfer.s.sol index 232468f..967de55 100644 --- a/evm/forge/scripts/TestTransfer.s.sol +++ b/evm/forge/scripts/TestTransfer.s.sol @@ -16,19 +16,19 @@ contract TestTransfer is Script { address immutable _router = vm.envAddress("RELEASE_TOKEN_ROUTER_ADDRESS"); // Transfer params. - uint256 _amountIn = vm.envUint("TEST_AMOUNT_IN"); + uint64 _amountIn = uint64(vm.envUint("TEST_AMOUNT_IN")); uint16 _targetChain = uint16(vm.envUint("TEST_TARGET_CHAIN")); bytes32 _redeemer = vm.envBytes32("TEST_REDEEMER"); bool isFast = vm.envBool("TEST_IS_FAST"); bytes _redeemerMessage = hex"deadbeef"; - uint128 _baseAuctionPrice = uint128(vm.envUint("TEST_BASE_AUCTION_PRICE")); + uint64 _maxFee = uint64(vm.envUint("TEST_FEE")); uint32 _deadline = uint32(vm.envUint("TEST_DEADLINE")); function transfer() public { SafeERC20.safeIncreaseAllowance(IERC20(_token), _router, _amountIn); if (isFast) { ITokenRouter(_router).placeFastMarketOrder( - _amountIn, _targetChain, _redeemer, _redeemerMessage, _baseAuctionPrice, _deadline + _amountIn, _targetChain, _redeemer, _redeemerMessage, _maxFee, _deadline ); } else { ITokenRouter(_router).placeMarketOrder( diff --git a/evm/forge/scripts/UpgradeTokenRouter.s.sol b/evm/forge/scripts/UpgradeTokenRouter.s.sol index 555b0cc..003c3c5 100644 --- a/evm/forge/scripts/UpgradeTokenRouter.s.sol +++ b/evm/forge/scripts/UpgradeTokenRouter.s.sol @@ -21,7 +21,6 @@ contract UpgradeTokenRouter is CheckWormholeContracts, Script { address immutable _token = vm.envAddress("RELEASE_TOKEN_ADDRESS"); address immutable _wormhole = vm.envAddress("RELEASE_WORMHOLE_ADDRESS"); address immutable _cctpTokenMessenger = vm.envAddress("RELEASE_TOKEN_MESSENGER_ADDRESS"); - address immutable _ownerAssistantAddress = vm.envAddress("RELEASE_OWNER_ASSISTANT_ADDRESS"); bytes32 immutable _matchingEngineAddress = vm.envBytes32("RELEASE_MATCHING_ENGINE_ADDRESS"); bytes32 immutable _matchingEngineMintRecipient = vm.envBytes32("RELEASE_MATCHING_ENGINE_MINT_RECIPIENT"); diff --git a/solana/.gitignore b/solana/.gitignore index 7a81cfa..a661686 100644 --- a/solana/.gitignore +++ b/solana/.gitignore @@ -5,6 +5,8 @@ .vscode **/*.rs.bk /artifacts-* +/cfg/**/*.json +!/cfg/**/sample.*.json /node_modules /target/* !/target/idl diff --git a/solana/README.md b/solana/README.md index c8178ef..b5673f3 100644 --- a/solana/README.md +++ b/solana/README.md @@ -1,91 +1,114 @@ -# Example Token Router on Solana +# Example Liquidity Layer on Solana -## Dependencies +## Assets + +- Matching Engine +- Token Router +- Upgrade Manager -> **Warning** -> Only Solana versions >= 1.14.14 and < 1.15 are supported. +## Dependencies -First, you will need `cargo` and `anchor` CLI tools. If you need these tools, -please visit the [Anchor book] for more details. +- cargo -- Get started using [rustup](https://rustup.rs/) +- npm -- Get started using [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script) +- solana -- Install the latest version [here](https://docs.solanalabs.com/cli/install#use-solanas-install-tool) +- anchor -- Install [avm](https://book.anchor-lang.com/getting_started/installation.html#anchor) ## Build -Once you have the above CLI tools, you can build the programs by simply running: +Currently there are two features that represent target Solana +networks: -- `NETWORK=testnet make` +- localnet (Solana Test Validator) +- testnet (Solana devnet) -This will also install this subdirectory's dependencies, such as -`node_modules` and the Wormhole programs from the `solana` directory of the -[Wormhole repo]. This will also create a keypair for the program in the -`target/deploy/`. This keypair can be used for devnet, testnet and mainnet. Do not delete this -key after deploying to the network of your choice. +Make sure the program pubkeys for whichever network you plan on deploying for is set as the correct +const values found in [this lib.rs file](modules/common/src/lib.rs). -## Tests +So for testnet, you would specify the NETWORK env and execute `make build`. For example: -To run both unit and integration tests, run `make test`. If you want to isolate -your testing, use either of these commands: +```sh +NETWORK=testnet make build +``` -- `make unit-test` - Runs `cargo clippy` and `cargo test` -- `make integration-test` - Spawns a solana local validator and uses `ts-mocha` - with `@solana/web3.js` to interact with the example programs. +This will create an artifacts directory (in the example above, _artifacts_testnet_). -## Deployment +## Tests -First, generate a program public key by running the following command: +To run both unit and integration tests, run `make test`. -- `solana-keygen pubkey target/deploy/token_bridge_relayer-keypair.json` +## Deployment -Add your program's public key to the following file: +First [build](#build) for a specific network. -- `programs/src/lib.rs` +Following the same example in the build section, assuming your keypair reflects the Upgrade +Manager's pubkey `ucdP9ktgrXgEUnn6roqD2SfdGMR2JSiWHUKv23oXwxt`, you would deploy a new program by +running the following command using the Solana CLI: -Then, build based on the target network. The deployment options are `devnet`, `testnet` and `mainnet`. We will use `testnet` as an example for this README. +```sh +solana program deploy -u d --program-id ucdP9ktgrXgEUnn6roqD2SfdGMR2JSiWHUKv23oXwxt artifacts-testnet/upgrade_manager.so +``` -- `NETWORK=testnet make build` +## Managing Upgrades -Next, we will need to create some keypairs for the deployment. The keypair that is used to deploy the program will become the `owner` of the program. Optionally, you can create a new keypair for the `assistant` and the `fee_recipient`, however, the same keypair can be used for all three. Create the keypair(s) in a location of your choice by running: +TODO -- `solana-keygen new -o path/to/keypair.json` +## Testnet Example Solver -Then set the `FEE_RECIPIENT`, `ASSISTANT` and `TOKEN_ROUTER_PID` in the `env/tesnet.env` file. This env file will be used for your deployment, as well as setting up the program. +The example solver is split up into three processes: `vaaAuctionRelayer`, `improveOffer` and +`executeOrder`. All three rely on the same configuration file, which can created by copying the +sample file `cfg/testnet/sample.config.json`. -Finally, deploy the program (from the `solana`) directory with the following command: +To get started, create a durable nonce account (see these +[instructions](https://solana.com/developers/guides/advanced/introduction-to-durable-nonces)) with +your Solana keypair. Copy the public key and move it to the `nonceAccount` field in your config. -``` -solana program deploy target/deploy/token_bridge_relayer.so \ - --program-id target/deploy/token_bridge_relayer-keypair.json \ - --commitment confirmed \ - -u your_testnet_rpc \ - -k your_deployment_keypair.json` -``` +**NOTE: We encourage using a durable nonce to avoid an expired blockhash error in case there is +network congestion. We demonstrate how to use this nonce account in the `vaaAuctionRelayer` +process.** -## Program Setup +Next, you'll need an RPC for each network that you wish to relay `FastMarketOrders` from. Add each +RPC to the config for the corresponding chain name. -### Step 1: Env File +Finally, you will need a funded USDC Associated Token Account (ATA), whose owner is your keypair. -You should still have your environment file from the [deployment](#deployment) section of this README. However (if you deleted it) create a new one and set the `FEE_RECIPIENT`, `ASSISTANT` and `TOKEN_ROUTER_PID` environment variables. +### Vaa Auction Relayer -### Step 2: Setup Configuration File +The `vaaAuctionRelayer` listens for `FastMarketOrder` VAAs emitted by the Liquidity Layer's network +of contracts. It determines if the `maxFee` encoded in the `FastMarketOrder` VAA is high enough to +participate in an auction, if it is, it executes a `place_initial_offer` instruction on the Solana +`MatchingEngine`. If any known token accounts are the highest bidder at the end of an auction, this +process will settle the auction by executing the `settle_auction_complete` instruction and posting +the finalized VAA associated with the auction's `FastMarketOrder` VAA. -Depending on your target network, there should be an example config file in the `cfg` directory. Open your file of choice and configure it to your liking. DO NOT change the name of this file. +To run the `vaaAuctionRelayer` execute the following command: -### Step 3: Initialize the program +```sh +npx ts-node ts/auction-participant/vaaAuctionRelayer/app.ts path/to/config/your.config.json +``` -Run the following command to initialize the program. Make sure to supply the keypair that was used to deploy the program: +### Improve Offers -- `source env/testnet.env && yarn initialize -k your_deployment_keypair.json` +The `improveOffer` process listens for `AuctionUpdated` events on the `MatchingEngine` via +websocket. Once an auction has been initiated, this process will determine if it is willing to +improve the offer based on the `pricing` parameters in your config. -### Step 4: Register Foreign Contracts +To run the `improveOffer` script, execute the following command: -- `source env/testnet.env && yarn register-contracts -k your_deployment_keypair.json -n testnet` +```sh +npx ts-node ts/auction-participant/improveOffer/app.ts path/to/config/your.config.json +``` -### Step 5: Register Tokens (Sets Swap Rate and Max Swap Amount) +### Execute Fast Orders -- `source env/testnet.env && yarn register-tokens -k your_deployment_keypair.json -n testnet` +The `executeOrder` process listens for `AuctionUpdated` events on the `MatchingEngine` via +websocket. At the end of an auction's duration (see `endSlot` of the `AuctionUpdated` event), this +process will execute the order reflecting this auction within the auction's grace period. -### Step 6: Set Relayer Fees +**NOTE: You will need an address lookup table for the execute order instructions because these +instructions require so many accounts. This LUT address can be added to your config.** -- `source env/testnet.env && yarn set-relayer-fees -k your_deployment_keypair.json -n testnet` +To run the `executeOrder` script, execute the following command: -[anchor book]: https://book.anchor-lang.com/getting_started/installation.html -[wormhole repo]: https://github.com/wormhole-foundation/wormhole/tree/dev.v2/solana +```sh +npx ts-node ts/auction-participant/executeOrder/app.ts path/to/config/your.config.json +``` diff --git a/solana/cfg/testnet/sample.config.json b/solana/cfg/testnet/sample.config.json new file mode 100644 index 0000000..cc4ccdb --- /dev/null +++ b/solana/cfg/testnet/sample.config.json @@ -0,0 +1,96 @@ +{ + "environment": "testnet", + "logging": { + "app": "warn", + "logic": "debug" + }, + "connection": { + "rpc": "https://api.devnet.solana.com", + "commitment": "confirmed", + "nonceAccount": "", + "addressLookupTable": "Bw7Em8X9ZAryx9efp3btk9tnAVx5JREcCw2hfzbyF8qS" + }, + "sourceTxHash": { + "maxRetries": 20, + "retryBackoff": 5000 + }, + "computeUnits": { + "verifySignatures": 25000, + "postVaa": 100000, + "settleAuctionNoneCctp": 250000, + "settleAuctionNoneLocal": 150000, + "settleAuctionComplete": 400000, + "initiateAuction": 100000 + }, + "knownAtaOwners": [], + "endpointConfig": [ + { + "chain": "sepolia", + "rpc": "", + "endpoint": "0x603541d1Cf7178C407aA7369b67CB7e0274952e2", + "chainType": 0 + }, + { + "chain": "avalanche", + "rpc": "", + "endpoint": "0x7353B29FDc79435dcC7ECc9Ac9F9b61d83B4E0F4", + "chainType": 0 + }, + { + "chain": "optimism_sepolia", + "rpc": "", + "endpoint": "0xc1Cf3501ef0b26c8A47759F738832563C7cB014A", + "chainType": 0 + }, + { + "chain": "arbitrum_sepolia", + "rpc": "", + "endpoint": "0xc1Cf3501ef0b26c8A47759F738832563C7cB014A", + "chainType": 0 + }, + { + "chain": "base_sepolia", + "rpc": "https://sepolia.base.org", + "endpoint": "0x4452B708C01d6aD7058a7541A3A82f0aD0A1abB1", + "chainType": 0 + }, + { + "chain": "polygon", + "rpc": "", + "endpoint": "0x3Ce8a3aC230Eb4bCE3688f2A1ab21d986a0A0B06", + "chainType": 0 + } + ], + "pricing": [ + { + "chain": "sepolia", + "probability": 0.0005, + "edgePctOfFv": 0 + }, + { + "chain": "avalanche", + "probability": 0.0005, + "edgePctOfFv": 0 + }, + { + "chain": "optimism_sepolia", + "probability": 0.0005, + "edgePctOfFv": 0 + }, + { + "chain": "arbitrum_sepolia", + "probability": 0.0005, + "edgePctOfFv": 0 + }, + { + "chain": "base_sepolia", + "probability": 0.0005, + "edgePctOfFv": 0 + }, + { + "chain": "polygon", + "probability": 0.1, + "edgePctOfFv": 0 + } + ] +} diff --git a/solana/package-lock.json b/solana/package-lock.json index 3049506..6d6006b 100644 --- a/solana/package-lock.json +++ b/solana/package-lock.json @@ -10,9 +10,11 @@ "@coral-xyz/anchor": "^0.29.0", "@solana/spl-token": "^0.4.0", "@solana/web3.js": "^1.87.6", + "@types/node-fetch": "^2.6.11", "dotenv": "^16.4.1", "ethers": "^5.7.2", "sha3": "^2.1.4", + "winston": "^3.13.0", "yargs": "^17.7.2" }, "devDependencies": { @@ -219,6 +221,14 @@ "protobufjs": "~6.11.2" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@confio/ics23": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@confio/ics23/-/ics23-0.6.8.tgz", @@ -434,6 +444,16 @@ "integrity": "sha512-KvvX58MGMWh7xA+N+deCfunkA/ZNDvFLw4YbOmX3f/XBIkqrVY7qlotfy2aNb1kgp6h4B6Yc8YawJPDTfvWX7g==", "optional": true }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@ethereumjs/common": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-2.6.5.tgz", @@ -2067,6 +2087,15 @@ "version": "18.16.0", "license": "MIT" }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/pbkdf2": { "version": "3.1.0", "license": "MIT", @@ -2081,6 +2110,11 @@ "@types/node": "*" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, "node_modules/@types/ws": { "version": "7.4.7", "license": "MIT", @@ -2312,6 +2346,11 @@ "node": "*" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -2689,6 +2728,15 @@ "node": ">=12" } }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -2703,6 +2751,37 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -3093,6 +3172,11 @@ "version": "8.0.0", "license": "MIT" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, "node_modules/error-polyfill": { "version": "0.1.3", "license": "MIT", @@ -3343,6 +3427,11 @@ "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", "peer": true }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "license": "MIT" @@ -3381,6 +3470,11 @@ "flat": "cli.js" } }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "node_modules/follow-redirects": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", @@ -3721,6 +3815,11 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/is-binary-path": { "version": "2.1.0", "dev": true, @@ -3796,6 +3895,17 @@ "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "dev": true, @@ -3969,6 +4079,11 @@ "keccak": "^3.0.2" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, "node_modules/libsodium": { "version": "0.7.13", "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.13.tgz", @@ -4106,6 +4221,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/logform": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", + "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/long": { "version": "4.0.0", "license": "Apache-2.0" @@ -4511,6 +4642,14 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/optimism": { "version": "0.17.5", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.17.5.tgz", @@ -4841,6 +4980,14 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/scrypt-js": { "version": "3.0.1", "license": "MIT" @@ -4926,6 +5073,14 @@ "node": ">=6" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/snake-case": { "version": "3.0.4", "license": "MIT", @@ -4965,6 +5120,14 @@ "source-map": "^0.6.0" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, "node_modules/statuses": { "version": "1.5.0", "license": "MIT", @@ -5082,6 +5245,11 @@ "node_modules/text-encoding-utf-8": { "version": "1.0.2" }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "node_modules/through": { "version": "2.3.8", "license": "MIT" @@ -5187,6 +5355,14 @@ "version": "0.0.3", "license": "MIT" }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-invariant": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", @@ -5375,6 +5551,40 @@ "bs58check": "<3.0.0" } }, + "node_modules/winston": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.13.0.tgz", + "integrity": "sha512-rwidmA1w3SE4j0E5MuIufFhyJPBDG7Nu71RkZor1p2+qHvJSZ9GYDA81AyleQcZbh/+V6HjeBdfnTZJm9rSeQQ==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.4.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.0.tgz", + "integrity": "sha512-ajBj65K5I7denzer2IYW6+2bNIVqLGDHqDw3Ow8Ohh+vdW+rv4MZ6eiDvHoKhfJFZ2auyN8byXieDDJ96ViONg==", + "dependencies": { + "logform": "^2.3.2", + "readable-stream": "^3.6.0", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/workerpool": { "version": "6.2.1", "dev": true, diff --git a/solana/package.json b/solana/package.json index 769faf8..daedbcb 100644 --- a/solana/package.json +++ b/solana/package.json @@ -13,9 +13,11 @@ "@coral-xyz/anchor": "^0.29.0", "@solana/spl-token": "^0.4.0", "@solana/web3.js": "^1.87.6", + "@types/node-fetch": "^2.6.11", "dotenv": "^16.4.1", "ethers": "^5.7.2", "sha3": "^2.1.4", + "winston": "^3.13.0", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/solana/ts/auction-participant/containers/CachedBlockhash.ts b/solana/ts/auction-participant/containers/CachedBlockhash.ts new file mode 100644 index 0000000..d15bfe1 --- /dev/null +++ b/solana/ts/auction-participant/containers/CachedBlockhash.ts @@ -0,0 +1,70 @@ +import { BlockhashWithExpiryBlockHeight, Commitment, Connection } from "@solana/web3.js"; +import * as winston from "winston"; + +export class CachedBlockhash { + private _cached?: BlockhashWithExpiryBlockHeight; + private _noise: number; + + private constructor() { + this._noise = 0; + } + + static async initialize( + connection: Connection, + updateBlockhashFrequency: number, + commitment: Commitment, + logger: winston.Logger, + ) { + const out = new CachedBlockhash(); + await connection.getLatestBlockhash(commitment).then((blockhash) => { + out.update(blockhash, { logger }); + }); + + let tryAgain = false; + connection.onSlotChange(async (info) => { + const { slot } = info; + + // Update the latest blockhash every `updateBlockhashFrequency` slots. + if (tryAgain || slot % updateBlockhashFrequency == 0) { + // No need to block. We'll just update the latest blockhash and use it when needed. + connection + .getLatestBlockhash(commitment) + .then((blockhash) => { + out.update(blockhash, { logger, slot }); + tryAgain = false; + }) + .catch((err) => { + logger.error(`${err.toString()}`); + tryAgain = true; + }); + } + }); + + return out; + } + + get latest(): BlockhashWithExpiryBlockHeight | undefined { + return this._cached; + } + + update( + fetched: BlockhashWithExpiryBlockHeight, + opts: { logger?: winston.Logger; slot?: number } = {}, + ) { + const { logger, slot } = opts; + if (logger) { + if (slot) { + logger.debug(`Update blockhash: ${fetched.blockhash}, slot: ${slot}`); + } else { + logger.debug(`Update blockhash: ${fetched.blockhash}`); + } + } + this._cached = fetched; + this._noise = 0; + } + + // This allows for unique signatures for each transactions. + addNoise(value: number) { + return value + this._noise++; + } +} diff --git a/solana/ts/auction-participant/containers/OfferToken.ts b/solana/ts/auction-participant/containers/OfferToken.ts new file mode 100644 index 0000000..b168487 --- /dev/null +++ b/solana/ts/auction-participant/containers/OfferToken.ts @@ -0,0 +1,62 @@ +import { BN } from "@coral-xyz/anchor"; +import * as splToken from "@solana/spl-token"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import * as winston from "winston"; +import { MatchingEngineProgram } from "../../../src/matchingEngine"; + +export class OfferToken { + private _authority: Keypair; + private _address: PublicKey; + private _balance: BN; + + private constructor(matchingEngine: MatchingEngineProgram, authority: Keypair) { + this._authority = authority; + this._address = splToken.getAssociatedTokenAddressSync( + matchingEngine.mint, + authority.publicKey, + ); + this._balance = new BN(0); + } + + static async initialize( + matchingEngine: MatchingEngineProgram, + authority: Keypair, + logger: winston.Logger, + ) { + const that = new OfferToken(matchingEngine, authority); + await that.fetchBalance(matchingEngine.program.provider.connection, logger); + + return that; + } + + get authority() { + return this._authority; + } + + get address() { + return this._address; + } + + get balance() { + return this._balance; + } + + updateBalance(tokenBalance: BN, opts: { logger?: winston.Logger; signature?: string }) { + const { logger, signature } = opts; + if (logger) { + if (signature) { + logger.debug(`Update token balance: ${tokenBalance.toString()}, tx: ${signature}`); + } else { + logger.debug(`Update token balance: ${tokenBalance.toString()}`); + } + } + this._balance = tokenBalance; + } + + async fetchBalance(connection: Connection, logger: winston.Logger) { + await splToken.getAccount(connection, this._address).then((token) => { + this._balance = new BN(token.amount.toString()); + logger.debug(`Set token balance: ${this._balance.toString()}`); + }); + } +} diff --git a/solana/ts/auction-participant/containers/index.ts b/solana/ts/auction-participant/containers/index.ts new file mode 100644 index 0000000..1409f17 --- /dev/null +++ b/solana/ts/auction-participant/containers/index.ts @@ -0,0 +1,2 @@ +export * from "./CachedBlockhash"; +export * from "./OfferToken"; diff --git a/solana/ts/auction-participant/executeOrder/app.ts b/solana/ts/auction-participant/executeOrder/app.ts new file mode 100644 index 0000000..dbe0c15 --- /dev/null +++ b/solana/ts/auction-participant/executeOrder/app.ts @@ -0,0 +1,202 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import "dotenv/config"; +import * as fs from "fs"; +import winston from "winston"; +import { Uint64, uint64ToBigInt } from "../../src/common"; +import { AuctionUpdated, MatchingEngineProgram } from "../../src/matchingEngine"; +import { CachedBlockhash } from "../containers"; +import * as utils from "../utils"; + +const MATCHING_ENGINE_PROGRAM_ID = "mPydpGUWxzERTNpyvTKdvS7v8kvw5sgwfiP8WQFrXVS"; +const USDC_MINT = new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); + +main(process.argv); + +async function main(argv: string[]) { + const cfgJson = JSON.parse(fs.readFileSync(argv[2], "utf-8")); + const cfg = new utils.AppConfig(cfgJson); + + const logger = utils.defaultLogger({ label: "auction", level: "debug" }); + logger.info("Start logging"); + + const connection = cfg.solanaConnection(); + + const matchingEngine = new MatchingEngineProgram( + connection, + MATCHING_ENGINE_PROGRAM_ID, + USDC_MINT, + ); + + if (process.env.SOLANA_PRIVATE_KEY === undefined) { + throw new Error("SOLANA_PRIVATE_KEY is undefined"); + } + + // We play here. + matchingEngine.onAuctionUpdated( + await onAuctionUpdateCallback( + matchingEngine, + cfg, + Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "base64")), + logger, + ), + ); +} + +type AuctionDetails = { + slot: bigint; + fastVaa: PublicKey; + execute: boolean; +}; + +class SlotOrderedAuctions { + private _slots: bigint[]; + private _auctionsPerSlot: Map; + private _auctions: Map; // stringified pubkeys as keys + + constructor() { + this._slots = []; + this._auctionsPerSlot = new Map(); + this._auctions = new Map(); + } + + addAuction( + endSlot: Uint64, + accounts: { auction: PublicKey; fastVaa: PublicKey }, + execute: boolean, + ) { + const { auction, fastVaa } = accounts; + + // Make sure we do not accidentally execute at the end slot. + const slot = uint64ToBigInt(endSlot) + 1n; + + if (!this._slots.includes(slot)) { + // Add then sort slots. + this._slots.push(slot); + this._slots.sort(); + // Init new list of pubkeys. + this._auctionsPerSlot.set(slot, []); + } + this._auctionsPerSlot.get(slot)!.push(auction); + this._auctions.set(auction.toString(), { slot, fastVaa, execute }); + } + + updateAuction(auction: PublicKey, execute: boolean) { + const key = auction.toString(); + const found = this._auctions.has(key); + if (found) { + this._auctions.get(key)!.execute = execute; + } + return found; + } + + headSlot(): bigint | null { + return this._slots[0] ?? null; + } + + dequeue(): ({ auction: PublicKey } & AuctionDetails)[] { + if (this.headSlot() == null) { + throw new Error("No auctions to dequeue"); + } + + const slot = this._slots.shift()!; + const details = this._auctionsPerSlot.get(slot)!.map((auction) => { + const key = auction.toString(); + const details = this._auctions.get(key)!; + this._auctions.delete(key); + return { auction, ...details }; + }); + this._auctionsPerSlot.delete(slot); + + return details.filter((deets) => deets.execute); + } +} + +async function onAuctionUpdateCallback( + matchingEngine: MatchingEngineProgram, + cfg: utils.AppConfig, + payer: Keypair, + logger: winston.Logger, +) { + const connection = matchingEngine.program.provider.connection; + const cachedBlockhash = await CachedBlockhash.initialize( + connection, + 32, // slots + "finalized", + logger, + ); + + // Make container that warehouses our auctions per slot. + const slotOrderedAuctions = new SlotOrderedAuctions(); + + connection.onSlotChange(async (slotInfo) => { + const currentSlot = uint64ToBigInt(slotInfo.slot); + const headSlot = slotOrderedAuctions.headSlot(); + if (currentSlot === headSlot) { + const details = slotOrderedAuctions.dequeue(); + logger.debug( + `current slot: ${currentSlot}, head slot: ${headSlot}, details.len = ${details.length}`, + ); + for (const deets of details) { + executeOrderWithRetry(deets); + } + } + }); + + async function executeOrderWithRetry(accounts: { auction: PublicKey; fastVaa: PublicKey }) { + const { auction, fastVaa } = accounts; + // Execute auction. + logger.info( + `Execute with retry, auction: ${auction.toString()}, fastVaa: ${fastVaa.toString()}`, + ); + + let success = false; + while (!success) { + success = await matchingEngine + .executeFastOrderTx( + { payer: payer.publicKey, fastVaa, auction }, + [payer], + { + feeMicroLamports: 69_420, + computeUnits: 290_000, + }, + { skipPreflight: true }, + ) + .then((preppedTx) => + utils.sendTx(connection, preppedTx, logger, cachedBlockhash.latest), + ) + .then((_) => true) + .catch((err) => { + logger.error(`${err.toString()}`); + logger.debug(`Retrying execute order for auction: ${auction.toString()}`); + return false; + }); + } + } + + // Account for recognized token accounts so we do not offer against "ourselves". + const ourTokenAccounts = Array.from(cfg.recognizedTokenAccounts()); + for (const ours of ourTokenAccounts) { + logger.info(`Recognized token account: ${ours.toString()}`); + } + + logger.info(`Matching Engine: ${matchingEngine.ID.toString()}`); + return async function (event: AuctionUpdated, slot: number, signature: string) { + // Do we want to play? + const { auction, vaa, bestOfferToken, endSlot } = event; + + // Skip if not ours. + const execute = ourTokenAccounts.find((ours) => ours.equals(bestOfferToken)) !== undefined; + + if (vaa !== null) { + logger.debug( + `Add auction: ${auction.toString()}, end slot: ${endSlot}, execute: ${execute}`, + ); + slotOrderedAuctions.addAuction(endSlot, { auction, fastVaa: vaa }, execute); + } else { + logger.debug( + `Update auction: ${auction.toString()}, end slot: ${endSlot}, execute: ${execute}`, + ); + slotOrderedAuctions.updateAuction(auction, execute); + } + }; +} diff --git a/solana/ts/auction-participant/improveOffer/app.ts b/solana/ts/auction-participant/improveOffer/app.ts new file mode 100644 index 0000000..32b28aa --- /dev/null +++ b/solana/ts/auction-participant/improveOffer/app.ts @@ -0,0 +1,167 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import "dotenv/config"; +import * as fs from "fs"; +import winston from "winston"; +import { Uint64, uint64ToBigInt } from "../../src/common"; +import { AuctionUpdated, MatchingEngineProgram } from "../../src/matchingEngine"; +import { CachedBlockhash, OfferToken } from "../containers"; +import * as utils from "../utils"; + +const MATCHING_ENGINE_PROGRAM_ID = "mPydpGUWxzERTNpyvTKdvS7v8kvw5sgwfiP8WQFrXVS"; +const USDC_MINT = new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); + +main(process.argv); + +async function main(argv: string[]) { + const cfgJson = JSON.parse(fs.readFileSync(argv[2], "utf-8")); + const cfg = new utils.AppConfig(cfgJson); + + const logger = utils.defaultLogger({ label: "auction", level: "debug" }); + logger.info("Start logging"); + + const connection = cfg.solanaConnection(); + + const matchingEngine = new MatchingEngineProgram( + connection, + MATCHING_ENGINE_PROGRAM_ID, + USDC_MINT, + ); + + if (process.env.SOLANA_PRIVATE_KEY === undefined) { + throw new Error("SOLANA_PRIVATE_KEY is undefined"); + } + + // We play here. + matchingEngine.onAuctionUpdated( + await onAuctionUpdateCallback( + matchingEngine, + cfg, + Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "base64")), + logger, + ), + ); +} + +export async function onAuctionUpdateCallback( + matchingEngine: MatchingEngineProgram, + cfg: utils.AppConfig, + participant: Keypair, + logger: winston.Logger, +) { + const connection = matchingEngine.program.provider.connection; + const cachedBlockhash = await CachedBlockhash.initialize( + connection, + 32, // slots + "finalized", + logger, + ); + + // Set up token account container. + const offerToken = await OfferToken.initialize(matchingEngine, participant, logger); + + // Account for recognized token accounts so we do not offer against "ourselves". + const ourTokenAccounts = Array.from(cfg.recognizedTokenAccounts()); + if (ourTokenAccounts.find((ours) => ours.equals(offerToken.address)) === undefined) { + ourTokenAccounts.push(offerToken.address); + } + for (const ours of ourTokenAccounts) { + logger.info(`Recognized token account: ${ours.toString()}`); + } + + // TODO: add to config + // 0.42069 lamports per compute unit. + const baseFeeLamports = 42069; + // 0.0001 lamports per amount in (e.g. 10 lamports per compute unit for 10,000 USDC). + const scaleAmountFeeLamports = 100; + + logger.info(`Matching Engine: ${matchingEngine.ID.toString()}`); + return async function (event: AuctionUpdated, slot: number, signature: string) { + // Do we want to play? + const { + configId, + auction, + vaa, + sourceChain, + bestOfferToken, + tokenBalanceBefore, + endSlot, + amountIn, + totalDeposit, + maxOfferPriceAllowed, + } = event; + + const pricingParams = cfg.pricingParameters(sourceChain); + if (pricingParams === null) { + logger.error(`No pricing parameters found for source chain: ${sourceChain}`); + return; + } + + if (ourTokenAccounts.find((ours) => ours.equals(bestOfferToken)) !== undefined) { + const tokenBalanceAfter = tokenBalanceBefore.sub(totalDeposit); + + if (bestOfferToken.equals(offerToken.address)) { + offerToken.updateBalance(tokenBalanceAfter, { logger, signature }); + } + + // Done. + return; + } + + // We cannot participate for any participation at this point. + if (endSlot.lten(slot)) { + logger.debug(`Skipping ended auction: ${auction.toString()}`); + return; + } + + logger.debug( + `Found ${ + vaa !== null ? "initial" : "improved" + } auction: ${auction.toString()}, source chain: ${sourceChain}, slot: ${slot}, end slot: ${endSlot}, tx: ${signature}`, + ); + + if (shouldImproveOffer(amountIn, maxOfferPriceAllowed, pricingParams)) { + const preppedTx = await matchingEngine.improveOfferTx( + { + participant: offerToken.authority.publicKey, + auction, + auctionConfig: matchingEngine.auctionConfigAddress(configId), + bestOfferToken, + }, + { offerPrice: maxOfferPriceAllowed, totalDeposit }, + [offerToken.authority], + { + feeMicroLamports: cachedBlockhash.addNoise( + baseFeeLamports + + scaleAmountFeeLamports * amountIn.divn(1_000_000).toNumber(), + ), + computeUnits: 50_000, + }, + { + skipPreflight: true, + }, + ); + + // Attempt to send without blocking. + utils.sendTx(connection, preppedTx, logger, cachedBlockhash.latest); + } else { + logger.debug(`Skipping too low offer: ${maxOfferPriceAllowed.toString()}`); + } + }; +} + +function shouldImproveOffer( + amountIn: Uint64, + maxOfferPriceAllowed: Uint64, + pricingParameters: utils.PricingParameters, +): boolean { + const PRECISION = 10000n; + + const fairValue = + (uint64ToBigInt(amountIn) * BigInt(pricingParameters.probability * Number(PRECISION))) / + PRECISION; + const fairValueWithEdge = + fairValue + + (fairValue * BigInt(pricingParameters.edgePctOfFv * Number(PRECISION))) / PRECISION; + + return fairValueWithEdge <= uint64ToBigInt(maxOfferPriceAllowed); +} diff --git a/solana/ts/auction-participant/utils/config.ts b/solana/ts/auction-participant/utils/config.ts new file mode 100644 index 0000000..3b4fa75 --- /dev/null +++ b/solana/ts/auction-participant/utils/config.ts @@ -0,0 +1,426 @@ +import * as wormholeSdk from "@certusone/wormhole-sdk"; +import * as splToken from "@solana/spl-token"; +import { Commitment, Connection, FetchFn, PublicKey, PublicKeyInitData } from "@solana/web3.js"; +import { ethers } from "ethers"; +import { USDC_MINT_ADDRESS } from "../../tests/helpers"; +import { defaultLogger } from "./logger"; + +export const EVM_FAST_CONSISTENCY_LEVEL = 200; + +export const CCTP_ATTESTATION_ENDPOINT_TESTNET = "https://iris-api-sandbox.circle.com"; +export const CCTP_ATTESTATION_ENDPOINT_MAINNET = "https://iris-api.circle.com"; +export const WORMHOLESCAN_VAA_ENDPOINT_TESTNET = "https://api.testnet.wormholescan.io/api/v1/vaas/"; +export const WORMHOLESCAN_VAA_ENDPOINT_MAINNET = "https://api.wormholescan.io/api/v1/vaas/"; + +enum Environment { + MAINNET = "mainnet", + TESTNET = "testnet", + DEVNET = "devnet", +} + +export type InputEndpointChainConfig = { + chain: wormholeSdk.ChainName; + chainType: ChainType; + rpc: string; + endpoint: string; +}; + +export type SourceTxHashConfig = { + maxRetries: number; + retryBackoff: number; +}; + +export type SolanaConnectionConfig = { + rpc: string; + ws?: string; + commitment: Commitment; + nonceAccount: PublicKeyInitData; + addressLookupTable: PublicKeyInitData; +}; + +export type LoggingConfig = { + app: string; + logic: string; +}; + +export type ComputeUnitsConfig = { + verifySignatures: number; + postVaa: number; + settleAuctionNoneCctp: number; + settleAuctionNoneLocal: number; + settleAuctionComplete: number; + initiateAuction: number; +}; + +export type PricingParameters = { + chain: wormholeSdk.ChainName; + probability: number; + edgePctOfFv: number; +}; + +export type EnvironmentConfig = { + environment: Environment; + logging: LoggingConfig; + connection: SolanaConnectionConfig; + sourceTxHash: SourceTxHashConfig; + computeUnits: ComputeUnitsConfig; + pricing: PricingParameters[]; + endpointConfig: InputEndpointChainConfig[]; + knownAtaOwners: string[]; +}; + +export type ChainConfig = InputEndpointChainConfig & { + fastConsistencyLevel?: number; +}; + +export enum ChainType { + Evm, + Solana, +} + +export class AppConfig { + private _cfg: EnvironmentConfig; + + private _chainCfgs: Partial<{ [k: number]: ChainConfig }>; + + private _wormholeAddresses: { + [k in wormholeSdk.ChainName]: { core?: string; token_bridge?: string; nft_bridge?: string }; + }; + + constructor(input: any) { + this._cfg = validateEnvironmentConfig(input); + + this._chainCfgs = this._cfg.endpointConfig + .map((cfg) => ({ + ...cfg, + fastConsistencyLevel: wormholeSdk.isEVMChain(cfg.chain) + ? EVM_FAST_CONSISTENCY_LEVEL + : undefined, + })) + .reduce((acc, cfg) => ({ ...acc, [wormholeSdk.coalesceChainId(cfg.chain)]: cfg }), {}); + + this._wormholeAddresses = + this._cfg.environment == Environment.MAINNET + ? wormholeSdk.utils.CONTRACTS.MAINNET + : wormholeSdk.utils.CONTRACTS.TESTNET; + } + + appLogLevel(): string { + return this._cfg.logging.app; + } + + logicLogLevel(): string { + return this._cfg.logging.logic; + } + + sourceTxHash(): { maxRetries: number; retryBackoff: number } { + return this._cfg.sourceTxHash; + } + + solanaConnection(debug: boolean = false): Connection { + const fetchLogger = defaultLogger({ label: "fetch", level: debug ? "debug" : "error" }); + fetchLogger.debug("Start debug logging Solana connection fetches."); + + return new Connection(this._cfg.connection.rpc, { + commitment: this._cfg.connection.commitment, + wsEndpoint: this._cfg.connection.ws, + fetchMiddleware: function ( + info: Parameters[0], + init: Parameters[1], + fetch: (...a: Parameters) => void, + ) { + if (init !== undefined) { + // @ts-ignore: init is not null + fetchLogger.debug(init.body!); + } + return fetch(info, init); + }, + }); + } + + solanaRpc(): string { + return this._cfg.connection.rpc; + } + + solanaCommitment(): Commitment { + return this._cfg.connection.commitment; + } + + solanaNonceAccount(): PublicKey { + return new PublicKey(this._cfg.connection.nonceAccount); + } + + solanaAddressLookupTable(): PublicKey { + return new PublicKey(this._cfg.connection.addressLookupTable); + } + + verifySignaturesComputeUnits(): number { + return this._cfg.computeUnits.verifySignatures; + } + + postVaaComputeUnits(): number { + return this._cfg.computeUnits.postVaa; + } + + settleAuctionNoneCctpComputeUnits(): number { + return this._cfg.computeUnits.settleAuctionNoneCctp; + } + + settleAuctionNoneLocalComputeUnits(): number { + return this._cfg.computeUnits.settleAuctionNoneLocal; + } + + settleAuctionCompleteComputeUnits(): number { + return this._cfg.computeUnits.settleAuctionComplete; + } + + initiateAuctionComputeUnits(): number { + return this._cfg.computeUnits.initiateAuction; + } + + knownAtaOwners(): PublicKey[] { + return this._cfg.knownAtaOwners.map((key) => new PublicKey(key)); + } + + recognizedTokenAccounts(): PublicKey[] { + return this.knownAtaOwners().map((key) => { + return splToken.getAssociatedTokenAddressSync(USDC_MINT_ADDRESS, key); + }); + } + + isRecognizedTokenAccount(tokenAccount: PublicKey): boolean { + return this.recognizedTokenAccounts().some((key) => key.equals(tokenAccount)); + } + + pricingParameters(chain: number): PricingParameters | null { + const pricing = this._cfg.pricing.find( + (p) => wormholeSdk.coalesceChainId(p.chain) == chain, + ); + + return pricing === undefined ? null : pricing; + } + + unsafeChainCfg(chain: number): { coreBridgeAddress: string } & ChainConfig { + const chainCfg = this._chainCfgs[chain]!; + return { + coreBridgeAddress: this._wormholeAddresses[chainCfg.chain].core!, + ...chainCfg, + }; + } + + cctpAttestationEndpoint(): string { + return this._cfg.environment == Environment.MAINNET + ? CCTP_ATTESTATION_ENDPOINT_MAINNET + : CCTP_ATTESTATION_ENDPOINT_TESTNET; + } + + wormholeScanVaaEndpoint(): string { + return this._cfg.environment == Environment.MAINNET + ? WORMHOLESCAN_VAA_ENDPOINT_MAINNET + : WORMHOLESCAN_VAA_ENDPOINT_TESTNET; + } + + emitterFilterForSpy(): { chain: wormholeSdk.ChainName; nativeAddress: string }[] { + return this._cfg.endpointConfig.map((cfg) => ({ + chain: cfg.chain, + nativeAddress: cfg.endpoint, + })); + } + + isFastFinality(vaa: wormholeSdk.ParsedVaa): boolean { + return ( + vaa.consistencyLevel == + this._chainCfgs[vaa.emitterChain as wormholeSdk.ChainId]?.fastConsistencyLevel + ); + } +} + +function validateEnvironmentConfig(cfg: any): EnvironmentConfig { + // check root keys + for (const key of Object.keys(cfg)) { + if ( + key !== "environment" && + key !== "connection" && + key !== "logging" && + key !== "sourceTxHash" && + key !== "computeUnits" && + key !== "endpointConfig" && + key !== "pricing" && + key !== "knownAtaOwners" + ) { + throw new Error(`unexpected key: ${key}`); + } else if (cfg[key] === undefined) { + throw new Error(`${key} is required`); + } + } + + // environment + if (cfg.environment !== Environment.MAINNET && cfg.environment !== Environment.TESTNET) { + throw new Error( + `environment must be either ${Environment.MAINNET} or ${Environment.TESTNET}`, + ); + } + + // connection + for (const key of Object.keys(cfg.connection)) { + if ( + key !== "rpc" && + key !== "ws" && + key !== "commitment" && + key !== "nonceAccount" && + key !== "addressLookupTable" + ) { + throw new Error(`unexpected key: connection.${key}`); + } else if (key !== "ws" && cfg.connection[key] === undefined) { + throw new Error(`connection.${key} is required`); + } + } + + // check nonce account pubkey + new PublicKey(cfg.connection.nonceAccount); + + // check address lookup table pubkey + new PublicKey(cfg.connection.addressLookupTable); + + // Make sure the ATA owners list is nonzero. + if (cfg.knownAtaOwners === undefined || cfg.knownAtaOwners.length === 0) { + throw new Error("knownAtaOwners must be a non-empty array"); + } + + // logging + for (const key of Object.keys(cfg.logging)) { + if (key !== "app" && key !== "logic") { + throw new Error(`unexpected key: logging.${key}`); + } else if (cfg.logging[key] === undefined) { + throw new Error(`logging.${key} is required`); + } + } + + // sourceTxHash + for (const key of Object.keys(cfg.sourceTxHash)) { + if (key !== "maxRetries" && key !== "retryBackoff") { + throw new Error(`unexpected key: sourceTxHash.${key}`); + } else if (cfg.sourceTxHash[key] === undefined) { + throw new Error(`sourceTxHash.${key} is required`); + } + } + + // computeUnits + for (const key of Object.keys(cfg.computeUnits)) { + if ( + key !== "verifySignatures" && + key !== "postVaa" && + key !== "settleAuctionNoneCctp" && + key !== "settleAuctionNoneLocal" && + key !== "settleAuctionComplete" && + key !== "initiateAuction" && + key !== "improveOffer" + ) { + throw new Error(`unexpected key: computeUnits.${key}`); + } else if (cfg.computeUnits[key] === undefined) { + throw new Error(`computeUnits.${key} is required`); + } + } + + // Pricing + if (!Array.isArray(cfg.pricing)) { + throw new Error("pricing must be an array"); + } + + for (const { chain, probability, edgePctOfFv } of cfg.pricing) { + if (chain === undefined) { + throw new Error("pricingParameter.chain is required"); + } else if (!(chain in wormholeSdk.CHAINS)) { + throw new Error(`invalid chain: ${chain}`); + } + + if (probability === undefined) { + throw new Error("pricingParameter.probability is required"); + } else if (typeof probability !== "number") { + throw new Error("pricingParameter.probability must be a number"); + } else if (probability <= 0 || probability > 1) { + throw new Error("pricingParameter.probability must be in (0, 1]"); + } + + if (edgePctOfFv === undefined) { + throw new Error("pricingParameter.edgePctOfFv is required"); + } else if (typeof edgePctOfFv !== "number") { + throw new Error("pricingParameter.edgePctOfFv must be a number"); + } else if (edgePctOfFv < 0) { + throw new Error("pricingParameter.edgePctOfFv must be non-negative"); + } + } + + // knownAtaOwners + if (!Array.isArray(cfg.knownAtaOwners)) { + throw new Error("knownAtaOwners must be an array"); + } + for (const knownAtaOwner of cfg.knownAtaOwners) { + new PublicKey(knownAtaOwner); + } + + // endpointConfig + if (!Array.isArray(cfg.endpointConfig)) { + throw new Error("endpointConfig must be an array"); + } + if (cfg.endpointConfig.length === 0) { + throw new Error("endpointConfig must contain at least one element"); + } + for (const { chain, rpc, endpoint, chainType } of cfg.endpointConfig) { + if (chain === undefined) { + throw new Error("endpointConfig.chain is required"); + } + if (!(chain in wormholeSdk.CHAINS)) { + throw new Error(`invalid chain: ${chain}`); + } + if (chainType === undefined) { + throw new Error("endpointConfig.chainType is required"); + } + if (chainType !== ChainType.Evm && chainType !== ChainType.Solana) { + throw new Error("endpointConfig.chainType must be either Evm or Solana"); + } + if (rpc === undefined) { + throw new Error("endpointConfig.rpc is required"); + } + if (endpoint === undefined) { + throw new Error("endpointConfig.endpoint is required"); + } + // Address should be checksummed. + if (endpoint != ethers.utils.getAddress(endpoint)) { + throw new Error( + `chain=${chain} address must be check-summed: ${ethers.utils.getAddress(endpoint)}`, + ); + } + // This should succeed. + wormholeSdk.tryNativeToHexString(endpoint, chain); + } + + return { + environment: cfg.environment, + connection: { + rpc: cfg.connection.rpc, + commitment: cfg.connection.commitment, + nonceAccount: cfg.connection.nonceAccount, + addressLookupTable: cfg.connection.addressLookupTable, + }, + logging: { + app: cfg.logging.app, + logic: cfg.logging.logic, + }, + sourceTxHash: { + maxRetries: cfg.sourceTxHash.maxRetries, + retryBackoff: cfg.sourceTxHash.retryBackoff, + }, + computeUnits: { + verifySignatures: cfg.computeUnits.verifySignatures, + postVaa: cfg.computeUnits.postVaa, + settleAuctionNoneCctp: cfg.computeUnits.settleAuctionNoneCctp, + settleAuctionNoneLocal: cfg.computeUnits.settleAuctionNoneLocal, + settleAuctionComplete: cfg.computeUnits.settleAuctionComplete, + initiateAuction: cfg.computeUnits.initiateAuction, + }, + pricing: cfg.pricing, + knownAtaOwners: cfg.knownAtaOwners, + endpointConfig: cfg.endpointConfig, + }; +} diff --git a/solana/ts/auction-participant/utils/evm/index.ts b/solana/ts/auction-participant/utils/evm/index.ts new file mode 100644 index 0000000..2d96836 --- /dev/null +++ b/solana/ts/auction-participant/utils/evm/index.ts @@ -0,0 +1 @@ +export * from "./wormholeCctp"; diff --git a/solana/ts/auction-participant/utils/evm/wormholeCctp.ts b/solana/ts/auction-participant/utils/evm/wormholeCctp.ts new file mode 100644 index 0000000..4209382 --- /dev/null +++ b/solana/ts/auction-participant/utils/evm/wormholeCctp.ts @@ -0,0 +1,95 @@ +import { ethers } from "ethers"; +import * as winston from "winston"; +import * as wormholeSdk from "@certusone/wormhole-sdk"; +import fetch from "node-fetch"; + +const WORMHOLE_MESSAGE = new ethers.utils.Interface([ + "event LogMessagePublished(address indexed sender,uint64 sequence,uint32 nonce,bytes payload,uint8 consistencyLevel)", +]); + +const CCTP_MESSAGE = new ethers.utils.Interface(["event MessageSent(bytes message)"]); + +export async function unsafeFindAssociatedCctpMessageAndAttestation( + rpc: string, + cctpAttestationEndpoint: string, + coreBridgeAddress: string, + txHash: string, + vaa: wormholeSdk.ParsedVaa, + logger: winston.Logger, +): Promise<{ encodedCctpMessage: Buffer; cctpAttestation: Buffer }> { + const { logs } = await new ethers.providers.StaticJsonRpcProvider(rpc).getTransactionReceipt( + txHash, + ); + + const wormholeMessageIndex = findWormholeMessageIndex(logs, coreBridgeAddress, vaa, logger)!; + + // We make the assumption that the CCTP events precede the Wormhole + // message that the Token Router publishes. We already checked the legitimacy + // of this Wormhole message by knowing the emitter and that this message is + // a slow order response. + const encodedCctpMessage = ethers.utils.arrayify( + CCTP_MESSAGE.parseLog(logs[wormholeMessageIndex - 2]).args.message, + ); + const cctpAttestation = await fetchCctpAttestation( + cctpAttestationEndpoint, + encodedCctpMessage, + logger, + ); + return { + encodedCctpMessage: Buffer.from(encodedCctpMessage), + cctpAttestation: Buffer.from(cctpAttestation), + }; +} + +function findWormholeMessageIndex( + logs: ethers.providers.Log[], + coreBridgeAddress: string, + vaa: wormholeSdk.ParsedVaa, + logger: winston.Logger, +): number | undefined { + for (let i = 0; i < logs.length; ++i) { + const log = logs[i]; + if (log.address != coreBridgeAddress) { + continue; + } + const { sequence } = WORMHOLE_MESSAGE.parseLog(log).args; + if (sequence.toString() == vaa.sequence.toString()) { + return i; + } + } + + logger.error( + `Could not find wormhole message for VAA: chain=${vaa.emitterChain}, sequence=${vaa.sequence}`, + ); +} + +async function fetchCctpAttestation( + cctpAttestationEndpoint: string, + encodedCctpMessage: Uint8Array, + logger: winston.Logger, +): Promise { + const attestationRequest = `${cctpAttestationEndpoint}/attestations/${ethers.utils.keccak256( + encodedCctpMessage, + )}`; + logger.info(`Attempting: ${attestationRequest}`); + + let attestationResponse: { status?: string; attestation?: string } = {}; + let j = 1; + while ( + attestationResponse.status != "complete" || + attestationResponse.attestation == undefined + ) { + logger.debug(`Attempting to fetch attestation, iteration=${j}`); + + const response = await fetch(attestationRequest); + attestationResponse = await response.json(); + + await new Promise((r) => setTimeout(r, j * 2000)); + ++j; + } + + const { attestation } = attestationResponse; + logger.debug(`Found attestation: ${attestation}`); + + return ethers.utils.arrayify(attestation!); +} diff --git a/solana/ts/auction-participant/utils/index.ts b/solana/ts/auction-participant/utils/index.ts new file mode 100644 index 0000000..c35ad3c --- /dev/null +++ b/solana/ts/auction-participant/utils/index.ts @@ -0,0 +1,56 @@ +export * from "./config"; +export * as evm from "./evm"; +export * from "./logger"; +export * from "./wormscan"; +export * from "./settleAuction"; +export * from "./sendTx"; +export * from "./preparePostVaaTx"; +export * from "./placeInitialOffer"; + +import { Connection, PublicKey } from "@solana/web3.js"; +import * as splToken from "@solana/spl-token"; +import { FastMarketOrder, LiquidityLayerMessage, SlowOrderResponse } from "../../src/common"; + +const USDC_MINT = new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); + +export async function getUsdcAtaBalance(connection: Connection, owner: PublicKey) { + const { amount } = await splToken.getAccount( + connection, + splToken.getAssociatedTokenAddressSync(USDC_MINT, owner), + ); + return amount; +} + +export async function isBalanceSufficient( + connection: Connection, + owner: PublicKey, + amount: bigint, +) { + return (await getUsdcAtaBalance(connection, owner)) >= amount; +} + +export function tryParseFastMarketOrder(payload: Buffer): FastMarketOrder | undefined { + try { + let { fastMarketOrder } = LiquidityLayerMessage.decode(payload); + if (fastMarketOrder === undefined) { + return undefined; + } else { + return fastMarketOrder; + } + } catch (err: any) { + return undefined; + } +} + +export function tryParseSlowOrderResponse(payload: Buffer): SlowOrderResponse | undefined { + try { + const { deposit } = LiquidityLayerMessage.decode(payload); + if (deposit === undefined || deposit.message.slowOrderResponse === undefined) { + return undefined; + } else { + return deposit.message.slowOrderResponse; + } + } catch (err: any) { + return undefined; + } +} diff --git a/solana/ts/auction-participant/utils/logger.ts b/solana/ts/auction-participant/utils/logger.ts new file mode 100644 index 0000000..2e74b45 --- /dev/null +++ b/solana/ts/auction-participant/utils/logger.ts @@ -0,0 +1,25 @@ +import * as winston from "winston"; + +export function defaultLogger(args: { label: string; level: string }): winston.Logger { + const { label, level } = args; + + return winston.createLogger({ + transports: [ + new winston.transports.Console({ + level, + }), + ], + format: winston.format.combine( + winston.format.colorize(), + winston.format.label({ label }), + winston.format.splat(), + winston.format.timestamp({ + format: "YYYY-MM-DD HH:mm:ss.SSS", + }), + winston.format.errors({ stack: true }), + winston.format.printf(({ level, message, label, timestamp }) => { + return `${timestamp} [${label}] ${level}: ${message}`; + }) + ), + }); +} diff --git a/solana/ts/auction-participant/utils/placeInitialOffer.ts b/solana/ts/auction-participant/utils/placeInitialOffer.ts new file mode 100644 index 0000000..f0f5124 --- /dev/null +++ b/solana/ts/auction-participant/utils/placeInitialOffer.ts @@ -0,0 +1,162 @@ +import * as wormholeSdk from "@certusone/wormhole-sdk"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole"; +import { PreparedTransaction } from "../../src"; +import { MatchingEngineProgram } from "../../src/matchingEngine"; +import { FastMarketOrder } from "../../src/common"; +import * as utils from "../utils"; +import * as winston from "winston"; + +export interface PlaceInitialOfferAccounts { + fastVaaAccount: PublicKey; + auction: PublicKey; + fromRouterEndpoint: PublicKey; + toRouterEndpoint: PublicKey; +} + +function getPlaceInitialOfferAccounts( + matchingEngine: MatchingEngineProgram, + fastVaaBytes: Uint8Array, + fromChain: wormholeSdk.ChainId | number, + toChain: wormholeSdk.ChainId | number, +): PlaceInitialOfferAccounts { + const fastVaaAccount = derivePostedVaaKey( + matchingEngine.coreBridgeProgramId(), + wormholeSdk.parseVaa(fastVaaBytes).hash, + ); + const auction = matchingEngine.auctionAddress( + wormholeSdk.keccak256(wormholeSdk.parseVaa(fastVaaBytes).hash), + ); + const fromRouterEndpoint = matchingEngine.routerEndpointAddress( + fromChain as wormholeSdk.ChainId, + ); + const toRouterEndpoint = matchingEngine.routerEndpointAddress(toChain as wormholeSdk.ChainId); + + return { + fastVaaAccount, + auction, + fromRouterEndpoint, + toRouterEndpoint, + }; +} + +function isFeeHighEnough( + fastOrder: FastMarketOrder, + pricingParameters: utils.PricingParameters, +): { shouldPlaceOffer: boolean; fvWithEdge: bigint } { + const precision = 10000; + const bnPrecision = BigInt(precision); + + const fairValue = + (fastOrder.amountIn * BigInt(pricingParameters.probability * precision)) / bnPrecision; + const fairValueWithEdge = + fairValue + (fairValue * BigInt(pricingParameters.edgePctOfFv * precision)) / bnPrecision; + + if (fairValueWithEdge > fastOrder.maxFee) { + return { shouldPlaceOffer: false, fvWithEdge: fairValueWithEdge }; + } else { + return { shouldPlaceOffer: true, fvWithEdge: fairValueWithEdge }; + } +} + +export async function handlePlaceInitialOffer( + connection: Connection, + cfg: utils.AppConfig, + matchingEngine: MatchingEngineProgram, + fastVaa: wormholeSdk.ParsedVaa, + rawVaa: Uint8Array, + order: FastMarketOrder, + payer: Keypair, + logicLogger: winston.Logger, +): Promise { + const unproccessedTxns: PreparedTransaction[] = []; + + // Derive accounts necessary to place the intial offer. We can bypass deriving these + // accounts by posting the VAA before generating the `placeIniitialOfferTx`, but we + // don't here to reduce complexity. + const { fastVaaAccount, auction, fromRouterEndpoint, toRouterEndpoint } = + getPlaceInitialOfferAccounts( + matchingEngine, + rawVaa, + fastVaa.emitterChain, + order.targetChain, + ); + + // Bail if the auction is already started. + const isAuctionStarted = await connection.getAccountInfo(auction).then((info) => info !== null); + + if (isAuctionStarted) { + logicLogger.warn(`Auction already started, sequence=${fastVaa.sequence}`); + return unproccessedTxns; + } + + // See if the `maxFee` meets our minimum price threshold. + const { shouldPlaceOffer, fvWithEdge } = isFeeHighEnough( + order, + cfg.pricingParameters(fastVaa.emitterChain)!, + ); + if (!shouldPlaceOffer) { + logicLogger.warn( + `Skipping sequence=${fastVaa.sequence} fee too low, maxFee=${order.maxFee}, fvWithEdge=${fvWithEdge}`, + ); + return unproccessedTxns; + } + + // See if we have enough funds to place the initial offer. + const notionalDeposit = await matchingEngine.computeNotionalSecurityDeposit( + order.amountIn, + 2, // TODO: Add this to config. + ); + const totalDeposit = order.amountIn + order.maxFee + notionalDeposit; + const isSufficient = utils.isBalanceSufficient(connection, payer.publicKey, totalDeposit); + + if (!isSufficient) { + logicLogger.warn( + `Insufficient balance to place initial offer, sequence=${fastVaa.sequence}`, + ); + return unproccessedTxns; + } + + // Create the instructions to post the fast VAA if it hasn't been posted already. + const isPosted = await connection.getAccountInfo(fastVaaAccount).then((info) => info !== null); + + if (!isPosted) { + logicLogger.debug(`Prepare verify signatures and post VAA, sequence=${fastVaa.sequence}`); + const preparedPostVaaTxs = await utils.preparePostVaaTxs( + connection, + cfg, + matchingEngine, + payer, + fastVaa, + { commitment: cfg.solanaCommitment() }, + ); + unproccessedTxns.push(...preparedPostVaaTxs); + } + + logicLogger.debug( + `Prepare initialize auction, sequence=${fastVaa.sequence}, auction=${auction}`, + ); + const initializeAuctionTx = await matchingEngine.placeInitialOfferTx( + { + payer: payer.publicKey, + fastVaa: fastVaaAccount, + auction, + fromRouterEndpoint, + toRouterEndpoint, + }, + { offerPrice: order.maxFee, totalDeposit }, + [payer], + { + computeUnits: cfg.initiateAuctionComputeUnits(), + feeMicroLamports: 10, + nonceAccount: cfg.solanaNonceAccount(), + }, + { + commitment: cfg.solanaCommitment(), + skipPreflight: isPosted ? false : true, + }, + ); + unproccessedTxns.push(initializeAuctionTx); + + return unproccessedTxns; +} diff --git a/solana/ts/auction-participant/utils/preparePostVaaTx.ts b/solana/ts/auction-participant/utils/preparePostVaaTx.ts new file mode 100644 index 0000000..eb54afe --- /dev/null +++ b/solana/ts/auction-participant/utils/preparePostVaaTx.ts @@ -0,0 +1,80 @@ +import * as wormholeSdk from "@certusone/wormhole-sdk"; +import { ConfirmOptions, Connection, Keypair, TransactionInstruction } from "@solana/web3.js"; +import { PreparedTransaction } from "../../src"; +import { MatchingEngineProgram } from "../../src/matchingEngine"; +import { AppConfig } from "./config"; + +function unsafeFixSigVerifyIx(sigVerifyIx: TransactionInstruction, sigVerifyIxIndex: number) { + const { data } = sigVerifyIx; + + const numSignatures = data.readUInt8(0); + + const offsetSpan = 11; + for (let i = 0; i < numSignatures; ++i) { + data.writeUInt8(sigVerifyIxIndex, 3 + i * offsetSpan); + data.writeUInt8(sigVerifyIxIndex, 6 + i * offsetSpan); + data.writeUInt8(sigVerifyIxIndex, 11 + i * offsetSpan); + } +} + +export async function preparePostVaaTxs( + connection: Connection, + cfg: AppConfig, + matchingEngine: MatchingEngineProgram, + payer: Keypair, + vaa: wormholeSdk.ParsedVaa, + confirmOptions?: ConfirmOptions, +): Promise { + const vaaSignatureSet = Keypair.generate(); + + // Check if Fast VAA has already been posted. + const vaaVerifySignaturesIxs = + await wormholeSdk.solana.createVerifySignaturesInstructionsSolana( + connection, + matchingEngine.coreBridgeProgramId(), + payer.publicKey, + vaa, + vaaSignatureSet.publicKey, + ); + vaaVerifySignaturesIxs.reverse(); + + const vaaPostIx = wormholeSdk.solana.createPostVaaInstructionSolana( + matchingEngine.coreBridgeProgramId(), + payer.publicKey, + vaa, + vaaSignatureSet.publicKey, + ); + + let preparedTransactions: PreparedTransaction[] = []; + while (vaaVerifySignaturesIxs.length > 0) { + const sigVerifyIx = vaaVerifySignaturesIxs.pop()!; + // This is a spicy meatball. Advance nonce ix + two compute budget ixs precede the + // sig verify ix. + unsafeFixSigVerifyIx(sigVerifyIx, 3); + const verifySigsIx = vaaVerifySignaturesIxs.pop()!; + + const preparedVerify: PreparedTransaction = { + ixs: [sigVerifyIx, verifySigsIx], + signers: [payer, vaaSignatureSet], + computeUnits: cfg.verifySignaturesComputeUnits(), + feeMicroLamports: 10, + nonceAccount: cfg.solanaNonceAccount(), + txName: "verifySignatures", + confirmOptions, + }; + + const preparedPost: PreparedTransaction = { + ixs: [vaaPostIx], + signers: [payer], + computeUnits: cfg.postVaaComputeUnits(), + feeMicroLamports: 10, + nonceAccount: cfg.solanaNonceAccount(), + txName: "postVAA", + confirmOptions, + }; + + preparedTransactions.push(preparedVerify, preparedPost); + } + + return preparedTransactions; +} diff --git a/solana/ts/auction-participant/utils/sendTx.ts b/solana/ts/auction-participant/utils/sendTx.ts new file mode 100644 index 0000000..a46fb1b --- /dev/null +++ b/solana/ts/auction-participant/utils/sendTx.ts @@ -0,0 +1,164 @@ +import { + ComputeBudgetProgram, + Connection, + SystemProgram, + PublicKey, + TransactionMessage, + VersionedTransaction, + TransactionInstruction, + BlockhashWithExpiryBlockHeight, +} from "@solana/web3.js"; +import * as winston from "winston"; +import { PreparedTransaction } from "../../src"; + +export async function getNonceAccountData( + connection: Connection, + nonceAccount: PublicKey, +): Promise<{ nonce: string; recentSlot: number; advanceIxs: TransactionInstruction[] }> { + const { context, value } = await connection.getNonceAndContext(nonceAccount); + if (context === null || value === null) { + throw new Error("Failed to fetch nonce account data"); + } + + return { + nonce: value.nonce, + recentSlot: context.slot, + advanceIxs: [ + SystemProgram.nonceAdvance({ + authorizedPubkey: value.authorizedPubkey, + noncePubkey: nonceAccount, + }), + ], + }; +} + +export async function sendTxBatch( + connection: Connection, + preparedTransactions: PreparedTransaction[], + logger?: winston.Logger, + retryCount?: number, +): Promise { + for (const preparedTransaction of preparedTransactions) { + const skipPreFlight = preparedTransaction.confirmOptions?.skipPreflight ?? false; + + // If skipPreFlight is false, we will retry the transaction if it fails. + let success = false; + let counter = 0; + while (!success && counter < (retryCount ?? 3)) { + const response = await sendTx(connection, preparedTransaction, logger); + + if (skipPreFlight) { + break; + } + + success = response.success; + counter++; + + if (logger !== undefined && !success) { + logger.warn(`Transaction failed, retrying, attempt=${counter}`); + } + } + } +} + +export async function sendTx( + connection: Connection, + preparedTransaction: PreparedTransaction, + logger?: winston.Logger, + cachedBlockhash?: BlockhashWithExpiryBlockHeight, +): Promise<{ success: boolean; txSig: string | void }> { + const { + nonceAccount, + ixs, + computeUnits, + feeMicroLamports, + signers, + addressLookupTableAccounts, + } = preparedTransaction; + + const payer = signers[0]; + const computeLimitIx = ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }); + const computeUnitPriceIx = ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: feeMicroLamports, + }); + + // Uptick nonce account, or fetch recent block hash. + const [messageV0, confirmStrategy] = await (async () => { + if (nonceAccount === undefined) { + const latestBlockhash = cachedBlockhash ?? (await connection.getLatestBlockhash()); + + return [ + new TransactionMessage({ + payerKey: payer.publicKey, + recentBlockhash: latestBlockhash.blockhash, + instructions: [computeLimitIx, computeUnitPriceIx, ...ixs], + }).compileToV0Message(addressLookupTableAccounts), + latestBlockhash, + ]; + } else { + const { nonce, recentSlot, advanceIxs } = await getNonceAccountData( + connection, + nonceAccount, + ); + + return [ + new TransactionMessage({ + payerKey: payer.publicKey, + recentBlockhash: nonce, + instructions: [...advanceIxs, computeLimitIx, computeUnitPriceIx, ...ixs], + }).compileToV0Message(addressLookupTableAccounts), + { + minContextSlot: recentSlot, + nonceAccountPubkey: nonceAccount, + nonceValue: nonce, + }, + ]; + } + })(); + + const tx = new VersionedTransaction(messageV0); + tx.sign(signers); + + let success = true; + let txSignature = await connection + .sendTransaction(tx, preparedTransaction.confirmOptions) + .then(async (signature) => { + const commitment = preparedTransaction.confirmOptions?.commitment; + if (commitment !== undefined) { + await connection.confirmTransaction( + { + signature, + ...confirmStrategy, + }, + commitment, + ); + } + return signature; + }) + .catch((err) => { + success = false; + + if (err.logs !== undefined) { + const logs: string[] = err.logs; + if (logger !== undefined) { + logger.error(logs.join("\n")); + } + } else { + if (logger !== undefined) { + logger.error("Txn failed with unknown error"); + } + } + }); + + if (logger !== undefined) { + if (preparedTransaction.txName !== undefined) { + logger.info( + `Transaction type: ${preparedTransaction.txName}, signature: ${txSignature}`, + ); + } else { + logger.info(`Transaction signature: ${txSignature}`); + } + } + + return { success, txSig: txSignature }; +} diff --git a/solana/ts/auction-participant/utils/settleAuction.ts b/solana/ts/auction-participant/utils/settleAuction.ts new file mode 100644 index 0000000..55819fb --- /dev/null +++ b/solana/ts/auction-participant/utils/settleAuction.ts @@ -0,0 +1,249 @@ +import * as wormholeSdk from "@certusone/wormhole-sdk"; +import { Connection, Keypair, PublicKey, Signer } from "@solana/web3.js"; +import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole"; +import * as splToken from "@solana/spl-token"; +import { PreparedTransaction } from "../../src"; +import { Auction, AuctionStatus, MatchingEngineProgram } from "../../src/matchingEngine"; +import { USDC_MINT_ADDRESS } from "../../tests/helpers"; +import * as utils from "../utils"; +import * as winston from "winston"; + +async function fetchCctpArgs( + cfg: utils.AppConfig, + logicLogger: winston.Logger, + finalizedVaa: wormholeSdk.ParsedVaa, + txHash: string, + fromChain: wormholeSdk.ChainName, + rpc: string, + coreBridgeAddress: string, +): Promise<{ encodedCctpMessage: Buffer; cctpAttestation: Buffer } | undefined> { + const cctpArgs = await (async () => { + if (wormholeSdk.isEVMChain(fromChain)) { + return utils.evm.unsafeFindAssociatedCctpMessageAndAttestation( + rpc, + cfg.cctpAttestationEndpoint(), + coreBridgeAddress, + txHash, + finalizedVaa, + logicLogger, + ); + } else { + logicLogger.error(`Unsupported chain: ${fromChain}`); + } + })(); + + return cctpArgs; +} + +export interface SettleAuctionAccounts { + finalizedVaaAccount: PublicKey; + fastVaaAccount: PublicKey; + auction: PublicKey; +} + +function getSettleAuctionAccounts( + matchingEngine: MatchingEngineProgram, + finalizedVaaBytes: Uint8Array | Buffer, + fastVaaBytes: Uint8Array | Buffer, +): SettleAuctionAccounts { + const auction = matchingEngine.auctionAddress( + wormholeSdk.keccak256(wormholeSdk.parseVaa(fastVaaBytes).hash), + ); + const finalizedVaaAccount = derivePostedVaaKey( + matchingEngine.coreBridgeProgramId(), + wormholeSdk.parseVaa(finalizedVaaBytes).hash, + ); + const fastVaaAccount = derivePostedVaaKey( + matchingEngine.coreBridgeProgramId(), + wormholeSdk.parseVaa(fastVaaBytes).hash, + ); + + return { + auction, + finalizedVaaAccount, + fastVaaAccount, + }; +} + +export async function handleSettleAuction( + connection: Connection, + cfg: utils.AppConfig, + matchingEngine: MatchingEngineProgram, + logicLogger: winston.Logger, + parsed: wormholeSdk.ParsedVaa, + raw: Uint8Array, + payer: Keypair, +): Promise { + // Since this testnet, Avax is enabled to send fast orders. + // We need to wait for the auction to be completed before we can settle it. + await new Promise((resolve) => setTimeout(resolve, 10_000)); + + const unproccessedTxns: PreparedTransaction[] = []; + + const { chain: fromChain, rpc, coreBridgeAddress } = cfg.unsafeChainCfg(parsed.emitterChain); + + // Fetch the fast vaa and the source transaction hash from wormscan. We subtract one from the + // slow vaa sequence to fetch the fast vaa bytes. + logicLogger.debug(`Attempting to fetch fast VAA, finalized sequence=${parsed.sequence}`); + const fastVaaSequence = Number(parsed.sequence) + 1; + const vaaResponse = await utils.fetchVaaFromWormscan( + cfg, + { + chain: parsed.emitterChain, + sequence: fastVaaSequence, + emitter: parsed.emitterAddress.toString("hex"), + }, + logicLogger, + ); + + if (vaaResponse.vaa === undefined || vaaResponse.txHash === undefined) { + logicLogger.error(`Failed to fetch fast VAA, sequence=${parsed.sequence}`); + return []; + } + + logicLogger.debug(`Attempting to parse fast VAA, sequence=${fastVaaSequence}`); + const fastVaaParsed = wormholeSdk.parseVaa(vaaResponse.vaa); + const fastOrder = utils.tryParseFastMarketOrder(fastVaaParsed.payload); + if (fastOrder === undefined) { + logicLogger.error(`Failed to parse FastMarketOrder, sequence=${fastVaaSequence}`); + return []; + } + + // Fetch accounts needed to settle the auction. + const { auction, finalizedVaaAccount, fastVaaAccount } = getSettleAuctionAccounts( + matchingEngine, + raw, + vaaResponse.vaa, + ); + + // Fetch the auction data. + let auctionData: Auction = {} as Auction; + try { + auctionData = await matchingEngine.fetchAuction({ address: auction }); + + if (!cfg.isRecognizedTokenAccount(auctionData.info!.bestOfferToken)) { + logicLogger.error( + `Auction winner token account is not recognized, sequence=${fastVaaParsed.sequence}`, + ); + return []; + } + } catch (e) { + logicLogger.error(`No auction found, sequence=${fastVaaParsed.sequence}`); + return []; + } + + // Fetch the CCTP message and attestation. + const cctpArgs = await fetchCctpArgs( + cfg, + logicLogger, + parsed, + vaaResponse.txHash, + fromChain, + rpc, + coreBridgeAddress, + ); + if (cctpArgs === undefined) { + logicLogger.error("Failed to fetch CCTP args"); + return []; + } + + // Create the instructions to post the fast VAA if it hasn't been posted already. + const isPosted = await connection + .getAccountInfo(finalizedVaaAccount) + .then((info) => info !== null); + + if (!isPosted) { + logicLogger.debug(`Prepare verify signatures and post VAA, sequence=${parsed.sequence}`); + const preparedPostVaaTxs = await utils.preparePostVaaTxs( + connection, + cfg, + matchingEngine, + payer, + parsed, + { commitment: cfg.solanaCommitment() }, + ); + unproccessedTxns.push(...preparedPostVaaTxs); + } + + logicLogger.debug( + `Prepare settle auction, sequence=${fastVaaParsed.sequence}, auction=${auction}`, + ); + const settleAuctionTx = await createSettleTx( + connection, + { + executor: payer.publicKey, + fastVaa: fastVaaAccount, + finalizedVaa: finalizedVaaAccount, + auction, + bestOfferToken: auctionData.info!.bestOfferToken, + }, + auctionData.status, + cctpArgs, + [payer], + matchingEngine, + cfg, + ); + + if (settleAuctionTx === undefined) { + logicLogger.debug(`Auction is not completed, sequence=${fastVaaParsed.sequence}`); + return []; + } else { + unproccessedTxns.push(settleAuctionTx!); + return unproccessedTxns; + } +} + +async function createSettleTx( + connection: Connection, + accounts: { + executor: PublicKey; + fastVaa: PublicKey; + finalizedVaa: PublicKey; + auction: PublicKey; + bestOfferToken: PublicKey; + }, + status: AuctionStatus, + cctpArgs: { encodedCctpMessage: Buffer; cctpAttestation: Buffer }, + signers: Signer[], + matchingEngine: MatchingEngineProgram, + cfg: utils.AppConfig, +): Promise { + const { executor, fastVaa, finalizedVaa, auction, bestOfferToken } = accounts; + + const { value: lookupTableAccount } = await connection.getAddressLookupTable( + cfg.solanaAddressLookupTable(), + ); + + // Fetch our token account. + const preparedTransactionOptions = { + computeUnits: cfg.settleAuctionCompleteComputeUnits(), + feeMicroLamports: 10, + nonceAccount: cfg.solanaNonceAccount(), + addressLookupTableAccounts: [lookupTableAccount!], + }; + const confirmOptions = { + commitment: cfg.solanaCommitment(), + skipPreflight: false, + }; + + if (status.completed === undefined) { + return undefined; + } + + // Prepare the settle auction transaction. + const settleAuctionTx = await matchingEngine.settleAuctionCompleteTx( + { + executor, + fastVaa, + finalizedVaa, + bestOfferToken, + auction, + }, + cctpArgs, + signers, + preparedTransactionOptions, + confirmOptions, + ); + + return settleAuctionTx; +} diff --git a/solana/ts/auction-participant/utils/wormscan.ts b/solana/ts/auction-participant/utils/wormscan.ts new file mode 100644 index 0000000..8d83f2c --- /dev/null +++ b/solana/ts/auction-participant/utils/wormscan.ts @@ -0,0 +1,50 @@ +import fetch from "node-fetch"; +import * as winston from "winston"; +import { AppConfig } from "./config"; + +interface VaaId { + chain: number; + sequence: number; + emitter: string; +} + +interface VaaResponse { + vaa?: Buffer; + txHash?: string; +} + +export async function fetchVaaFromWormscan( + cfg: AppConfig, + vaaId: VaaId, + logger: winston.Logger, +): Promise { + const wormscanRequest = `${cfg.wormholeScanVaaEndpoint()}${vaaId.chain}/${vaaId.emitter}/${ + vaaId.sequence + }`; + + const { maxRetries, retryBackoff } = cfg.sourceTxHash(); + + let vaaResponse: VaaResponse = {}; + let retries = 0; + while ( + vaaResponse.txHash == undefined && + vaaResponse.vaa == undefined && + retries < maxRetries + ) { + const backoff = retries * retryBackoff; + logger.debug( + `Requesting VaaResponse from Wormscan, retries=${retries}, maxRetries=${maxRetries}`, + ); + + await new Promise((resolve) => setTimeout(resolve, backoff)); + + const response = await fetch(wormscanRequest); + const parsedResponse = await response.json(); + vaaResponse.txHash = "0x" + parsedResponse.data.txHash; + vaaResponse.vaa = Buffer.from(parsedResponse.data.vaa, "base64"); + + ++retries; + } + + return vaaResponse; +} diff --git a/solana/ts/auction-participant/vaaAuctionRelayer/app.ts b/solana/ts/auction-participant/vaaAuctionRelayer/app.ts new file mode 100644 index 0000000..c7fa734 --- /dev/null +++ b/solana/ts/auction-participant/vaaAuctionRelayer/app.ts @@ -0,0 +1,137 @@ +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import "dotenv/config"; +import * as fs from "fs"; +import { MatchingEngineProgram } from "../../src/matchingEngine"; +import { PreparedTransaction } from "../../src"; +import * as utils from "../utils"; +import * as winston from "winston"; +import { VaaSpy } from "../../src/wormhole/spy"; + +const MATCHING_ENGINE_PROGRAM_ID = "mPydpGUWxzERTNpyvTKdvS7v8kvw5sgwfiP8WQFrXVS"; +const USDC_MINT = new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); +const DELAYED_VAA_THRESHOLD = 60; // Seconds. + +// Spy config. +const SPY_HOST = "localhost:7073"; +const ENABLE_CLEANUP = true; +const SEEN_THRESHOLD_MS = 1_500_000; +const INTERVAL_MS = 500; +const MAX_TO_REMOVE = 5; + +main(process.argv); + +async function main(argv: string[]) { + const cfgJson = JSON.parse(fs.readFileSync(argv[2], "utf-8")); + const cfg = new utils.AppConfig(cfgJson); + + const connection = new Connection(cfg.solanaRpc(), cfg.solanaCommitment()); + const matchingEngine = new MatchingEngineProgram( + connection, + MATCHING_ENGINE_PROGRAM_ID, + USDC_MINT, + ); + + if (process.env.SOLANA_PRIVATE_KEY === undefined) { + throw new Error("SOLANA_PRIVATE_KEY is undefined"); + } + const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "base64")); + + const logicLogger = utils.defaultLogger({ label: "logic", level: cfg.logicLogLevel() }); + logicLogger.debug("Start logging logic"); + + const transactionBatchQueue: PreparedTransaction[][] = []; + + spawnTransactionProcessor(connection, transactionBatchQueue, logicLogger); + + // Connect to spy. + const spy = new VaaSpy({ + spyHost: SPY_HOST, + vaaFilters: cfg.emitterFilterForSpy(), + enableCleanup: ENABLE_CLEANUP, + seenThresholdMs: SEEN_THRESHOLD_MS, + intervalMs: INTERVAL_MS, + maxToRemove: MAX_TO_REMOVE, + }); + + spy.onObservation(async ({ raw, parsed, chain }) => { + let txnBatch: PreparedTransaction[] = []; + + if (cfg.isFastFinality(parsed)) { + // Since were using the vaa timestamp, there is potentially some clock drift. However, + // we don't want to accept VAA's that are too far in the past. + const currTime = Math.floor(Date.now() / 1000); + if (currTime - parsed.timestamp > DELAYED_VAA_THRESHOLD) { + logicLogger.info( + `Ignoring stale Fast VAA, chain=${chain}, sequence=${parsed.sequence}, unixTime=${currTime}, vaaTime=${parsed.timestamp}`, + ); + return; + } else { + logicLogger.debug( + `Received valid Fast VAA, chain=${chain}, sequence=${parsed.sequence}, unixTime=${currTime}, vaaTime=${parsed.timestamp}`, + ); + } + + // Start a new auction if this is a fast VAA. + logicLogger.debug(`Attempting to parse FastMarketOrder, sequence=${parsed.sequence}`); + const fastOrder = utils.tryParseFastMarketOrder(parsed.payload); + if (fastOrder !== undefined) { + const unprocessedTxns = await utils.handlePlaceInitialOffer( + connection, + cfg, + matchingEngine, + parsed, + raw, + fastOrder, + payer, + logicLogger, + ); + txnBatch.push(...unprocessedTxns); + } else { + logicLogger.warn(`Failed to parse FastMarketOrder, sequence=${parsed.sequence}`); + return; + } + } else { + logicLogger.debug(`Attempting to parse SlowOrderResponse, sequence=${parsed.sequence}`); + const slowOrderResponse = utils.tryParseSlowOrderResponse(parsed.payload); + if (slowOrderResponse !== undefined) { + const unprocessedTxns = await utils.handleSettleAuction( + connection, + cfg, + matchingEngine, + logicLogger, + parsed, + raw, + payer, + ); + txnBatch.push(...unprocessedTxns); + } else { + logicLogger.warn(`Failed to parse SlowOrderResponse, sequence=${parsed.sequence}`); + return; + } + } + + // Push transaction batch to queue. + if (txnBatch.length > 0) { + transactionBatchQueue.push(txnBatch); + } + }); +} + +async function spawnTransactionProcessor( + connection: Connection, + preparedTransactionQueue: PreparedTransaction[][], + logger: winston.Logger, +) { + while (true) { + if (preparedTransactionQueue.length == 0) { + // Finally sleep so we don't spin so hard. + await new Promise((resolve) => setTimeout(resolve, 100)); + } else { + logger.debug(`Found queued batches (length=${preparedTransactionQueue.length})`); + const preparedBatch = preparedTransactionQueue.shift()!; + + // No await, just fire away. + utils.sendTxBatch(connection, preparedBatch, logger); + } + } +} diff --git a/solana/ts/enactNewTestnetAuctionConfig.ts b/solana/ts/enactNewTestnetAuctionConfig.ts new file mode 100644 index 0000000..c85d7e9 --- /dev/null +++ b/solana/ts/enactNewTestnetAuctionConfig.ts @@ -0,0 +1,88 @@ +import { + Connection, + Keypair, + PublicKey, + Transaction, + sendAndConfirmTransaction, +} from "@solana/web3.js"; +import "dotenv/config"; +import { uint64ToBN } from "../src/common"; +import { AuctionParameters, MatchingEngineProgram } from "../src/matchingEngine"; + +const PROGRAM_ID = "mPydpGUWxzERTNpyvTKdvS7v8kvw5sgwfiP8WQFrXVS"; +const USDC_MINT = new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); + +const AUCTION_PARAMS: AuctionParameters = { + userPenaltyRewardBps: 400_000, // 40% + initialPenaltyBps: 250_000, // 25% + duration: 5, // slots + gracePeriod: 10, // slots + penaltyPeriod: 20, // slots + minOfferDeltaBps: 50_000, // 5% + securityDepositBase: uint64ToBN(1_000_000), // 1 USDC + securityDepositBps: 5_000, // 0.5% +}; + +// Here we go. +main(); + +// impl + +async function main() { + const connection = new Connection("https://api.devnet.solana.com", "confirmed"); + const matchingEngine = new MatchingEngineProgram(connection, PROGRAM_ID, USDC_MINT); + + if (process.env.SOLANA_PRIVATE_KEY === undefined) { + throw new Error("SOLANA_PRIVATE_KEY is undefined"); + } + const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "base64")); + + // Propose new parameters. + await propose(matchingEngine, payer); + + // Wait a little over 10 seconds. + await new Promise((resolve) => setTimeout(resolve, 12_000)); + + // Update. + await enact(matchingEngine, payer); +} + +async function propose(matchingEngine: MatchingEngineProgram, payer: Keypair) { + const connection = matchingEngine.program.provider.connection; + + const custodian = matchingEngine.custodianAddress(); + console.log("custodian", custodian.toString()); + + const ix = await matchingEngine.proposeAuctionParametersIx( + { + ownerOrAssistant: payer.publicKey, + }, + AUCTION_PARAMS, + ); + + await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]) + .catch((err) => { + console.log(err.logs); + throw err; + }) + .then((txSig) => { + console.log("proposal", txSig); + }); +} + +async function enact(matchingEngine: MatchingEngineProgram, payer: Keypair) { + const connection = matchingEngine.program.provider.connection; + + const ix = await matchingEngine.updateAuctionParametersIx({ + owner: payer.publicKey, + }); + + await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]) + .catch((err) => { + console.log(err.logs); + throw err; + }) + .then((txSig) => { + console.log("updated", txSig); + }); +} diff --git a/solana/ts/scripts/executeFastOrder.ts b/solana/ts/scripts/executeFastOrder.ts new file mode 100644 index 0000000..7d2f38d --- /dev/null +++ b/solana/ts/scripts/executeFastOrder.ts @@ -0,0 +1,111 @@ +import { Keypair, Connection, Signer } from "@solana/web3.js"; +import { MatchingEngineProgram } from "../src/matchingEngine"; +import { USDC_MINT_ADDRESS } from "../tests/helpers"; +import { + ChainId, + getEmitterAddressEth, + getSignedVAAWithRetry, + parseVaa, +} from "@certusone/wormhole-sdk"; +import { LiquidityLayerMessage } from "../src/common"; +import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole"; +import * as utils from "../auction-participant/utils"; +import yargs from "yargs"; +import * as fs from "fs"; + +const MATCHING_ENGINE_PROGRAM_ID = "mPydpGUWxzERTNpyvTKdvS7v8kvw5sgwfiP8WQFrXVS"; + +export function getArgs() { + const argv = yargs.options({ + keyPair: { + alias: "k", + describe: "Signer Keypair", + require: true, + string: true, + }, + cfg: { + alias: "c", + describe: "config", + require: true, + string: true, + }, + fromChain: { + alias: "f", + describe: "fromChain", + require: true, + string: true, + }, + sequence: { + alias: "s", + describe: "sequence", + require: true, + string: true, + }, + }).argv; + + if ("keyPair" in argv && "cfg" in argv && "fromChain" in argv && "sequence" in argv) { + return { + keyPair: JSON.parse(fs.readFileSync(argv.keyPair, "utf8")), + cfgJson: JSON.parse(fs.readFileSync(argv.cfg, "utf-8")), + fromChain: argv.fromChain, + sequence: argv.sequence, + }; + } else { + throw Error("Invalid arguments"); + } +} + +async function main() { + // Owner wallet. + const { keyPair, cfgJson, fromChain, sequence } = getArgs(); + const cfg = new utils.AppConfig(cfgJson); + const connection = new Connection(cfg.solanaRpc(), "confirmed"); + const payer = Keypair.fromSecretKey(Uint8Array.from(keyPair)); + const signers: Signer[] = [payer]; + + const matchingEngine = new MatchingEngineProgram( + connection, + MATCHING_ENGINE_PROGRAM_ID, + USDC_MINT_ADDRESS, + ); + + const logicLogger = utils.defaultLogger({ label: "logic", level: cfg.logicLogLevel() }); + + const vaaResponse = await utils.fetchVaaFromWormscan( + cfg, + { + chain: parseInt(fromChain), + sequence: parseInt(sequence), + emitter: getEmitterAddressEth(cfg.unsafeChainCfg(parseInt(fromChain)).endpoint), + }, + logicLogger, + ); + + const parsedVaa = parseVaa(vaaResponse.vaa!); + const fastOrder = LiquidityLayerMessage.decode(parsedVaa.payload).fastMarketOrder; + if (fastOrder === undefined) { + throw new Error("Failed to parse FastMarketOrder"); + } + + const fastVaaAccount = derivePostedVaaKey(matchingEngine.coreBridgeProgramId(), parsedVaa.hash); + + const { value: lookupTableAccount } = await connection.getAddressLookupTable( + cfg.solanaAddressLookupTable(), + ); + + const tx = await matchingEngine.executeFastOrderTx( + { payer: payer.publicKey, fastVaa: fastVaaAccount }, + signers, + { + computeUnits: 250_000, + feeMicroLamports: 10, + nonceAccount: cfg.solanaNonceAccount(), + addressLookupTableAccounts: [lookupTableAccount!], + }, + { preflightCommitment: "confirmed", skipPreflight: false }, + ); + + await utils.sendTx(connection, tx, logicLogger); +} + +main(); diff --git a/solana/ts/scripts/getTestnetInfo.ts b/solana/ts/scripts/getTestnetInfo.ts index 7ed7ecf..8b0f4b9 100644 --- a/solana/ts/scripts/getTestnetInfo.ts +++ b/solana/ts/scripts/getTestnetInfo.ts @@ -40,23 +40,20 @@ async function main() { const matchingEngine = new matchingEngineSdk.MatchingEngineProgram( connection, matchingEngineSdk.testnet(), - USDC_MINT + USDC_MINT, ); const custodian = matchingEngine.custodianAddress(); - console.log(`Matching Engine Custodian: ${custodian.toString()}`); - console.log(); - console.log("NOTE: The Custodian's address is the Matching Engine's emitter."); - console.log(`Emitter Address: ${custodian.toBuffer().toString("hex")}`); + console.log("Matching Engine"); + console.log(" Custodian (NOTE: The Custodian's address is the program's emitter):"); + console.log(` Native: ${custodian.toString()}`); + console.log(` Universal: ${custodian.toBuffer().toString("hex")}`); console.log(); - const custodyToken = matchingEngine.custodyTokenAccountAddress(); - console.log(`Matching Engine Custody Token: ${custodyToken.toString()}`); - console.log(); - console.log( - "NOTE: The Custody Token Account's address is the Matching Engine's mint recipient." - ); - console.log(`Mint Recipient Address: ${custodyToken.toBuffer().toString("hex")}`); + const cctpMintRecipient = matchingEngine.cctpMintRecipientAddress(); + console.log(" Mint Recipient:"); + console.log(` Native: ${cctpMintRecipient.toString()}`); + console.log(` Universal: ${cctpMintRecipient.toBuffer().toString("hex")}`); console.log(); const custodianData = await matchingEngine.fetchCustodian(); @@ -65,17 +62,26 @@ async function main() { console.log(); const auctionConfig = await matchingEngine.fetchAuctionConfig( - custodianData.auctionConfigId + custodianData.auctionConfigId, ); console.log("Auction Config Data"); console.log(JSON.stringify(auctionConfig, null, 2)); console.log(); for (const chainName of CHAINS) { - const chain = coalesceChainId(chainName); - const endpointData = await matchingEngine.fetchRouterEndpoint(chain); - console.log(`Router Endpoint: ${chainName} (${chain})`); - console.log(stringifyEndpoint(chainName, endpointData)); + await matchingEngine + .fetchRouterEndpoint(coalesceChainId(chainName)) + .then((endpointData) => { + console.log( + `Registered Endpoint (${chainName}): ${stringifyEndpoint( + chainName, + endpointData, + )}`, + ); + }) + .catch((_) => { + console.log(`Not Registered: ${chainName}`); + }); console.log(); } } @@ -84,23 +90,20 @@ async function main() { const tokenRouter = new tokenRouterSdk.TokenRouterProgram( connection, tokenRouterSdk.testnet(), - USDC_MINT + USDC_MINT, ); const custodian = tokenRouter.custodianAddress(); - console.log(`Token Router Custodian: ${custodian.toString()}`); - console.log(); - console.log("NOTE: The Custodian's address is the Token Router's emitter."); - console.log(`Emitter Address: ${custodian.toBuffer().toString("hex")}`); + console.log(`Token Router`); + console.log(" Custodian (NOTE: The Custodian's address is the program's emitter):"); + console.log(` Native: ${custodian.toString()}`); + console.log(` Universal: ${custodian.toBuffer().toString("hex")}`); console.log(); - const custodyToken = tokenRouter.custodyTokenAccountAddress(); - console.log(`Token Router Custody Token: ${custodyToken.toString()}`); - console.log(); - console.log( - "NOTE: The Custody Token Account's address is the Token Router's mint recipient." - ); - console.log(`Mint Recipient Address: ${custodyToken.toBuffer().toString("hex")}`); + const cctpMintRecipient = tokenRouter.cctpMintRecipientAddress(); + console.log(" Mint Recipient:"); + console.log(` Native: ${cctpMintRecipient.toString()}`); + console.log(` Universal: ${cctpMintRecipient.toBuffer().toString("hex")}`); console.log(); } } diff --git a/solana/ts/scripts/improveAuctionOfferAndExecute/AuctionParticipant.ts b/solana/ts/scripts/improveAuctionOfferAndExecute/AuctionParticipant.ts deleted file mode 100644 index 7fe01aa..0000000 --- a/solana/ts/scripts/improveAuctionOfferAndExecute/AuctionParticipant.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Connection, Context, Logs, MessageCompiledInstruction, PublicKey } from "@solana/web3.js"; -import * as winston from "winston"; -import { MatchingEngineProgram } from "../../src/matchingEngine"; - -const PLACE_INITIAL_OFFER_SELECTOR = Uint8Array.from([170, 227, 204, 195, 210, 9, 219, 220]); -const IMPROVE_OFFER_SELECTOR = Uint8Array.from([171, 112, 46, 172, 194, 135, 23, 102]); - -export class AuctionParticipant { - private _logger: winston.Logger; - private _matchingEngine: MatchingEngineProgram; - private _connection: Connection; - - private _recognizedPayers: PublicKey[]; - - private _ourAuctions: Map; - - constructor( - matchingEngine: MatchingEngineProgram, - recognizedPayers: string[], - logger: winston.Logger, - ) { - this._logger = logger; - this._matchingEngine = matchingEngine; - this._connection = matchingEngine.program.provider.connection; - - this._recognizedPayers = recognizedPayers.map((keyInit) => new PublicKey(keyInit)); - - this._ourAuctions = new Map(); - } - - async onLogsCallback() { - const logger = this._logger; - const matchingEngine = this._matchingEngine; - - const connection = this._connection; - if (connection.commitment !== undefined) { - logger.info(`Connection established with "${connection.commitment}" commitment`); - } - - const recognizedPayers = this._recognizedPayers; - for (const recognized of recognizedPayers) { - logger.info(`Recognized payer: ${recognized.toString()}`); - } - - logger.info("Fetching active auction config"); - const { auctionConfigId } = await matchingEngine.fetchCustodian(); - const { parameters } = await matchingEngine.fetchAuctionConfig(auctionConfigId); - logger.info(`Found auction config with ID: ${auctionConfigId.toString()}`); - - logger.info( - `Listen to transaction logs from Matching Engine: ${matchingEngine.ID.toString()}`, - ); - return async function (logs: Logs, ctx: Context) { - if (logs.err !== null) { - return; - } - - logger.debug( - `Found signature: ${logs.signature} at slot ${ctx.slot}. Fetching transaction.`, - ); - - // TODO: save sigs to db and check if we've already processed this. - - // WARNING: When using get parsed transaction and there is a LUT involved, - const txMessage = await connection - .getTransaction(logs.signature, { - maxSupportedTransactionVersion: 0, - }) - .then((response) => response?.transaction.message); - if (txMessage === undefined) { - logger.warn(`Failed to fetch transaction with ${logs.signature}`); - return; - } - - if ( - recognizedPayers.find((recognized) => - recognized.equals(txMessage.staticAccountKeys[0]), - ) !== undefined - ) { - logger.debug("I recognize a payer. Disregard?"); - // return; - } else { - logger.debug(`Who is this? ${txMessage.staticAccountKeys[0].toString()}`); - } - - for (const ix of txMessage.compiledInstructions) { - const offerAmount = getOfferAmount(ix, logger); - if (offerAmount !== null) { - const improveOfferBy = await matchingEngine.computeMinOfferDelta( - offerAmount, - parameters, - ); - logger.debug(`Improve offer? ${offerAmount - improveOfferBy}`); - } - } - }; - } -} - -function getOfferAmount(ix: MessageCompiledInstruction, logger: winston.Logger) { - const data = Buffer.from(ix.data); - - const discriminator = data.subarray(0, 8); - if (discriminator.equals(PLACE_INITIAL_OFFER_SELECTOR)) { - const offerAmount = data.readBigUInt64LE(8); - logger.debug(`Found initial offer for ${offerAmount}`); - return offerAmount; - } else if (discriminator.equals(IMPROVE_OFFER_SELECTOR)) { - const offerAmount = data.readBigUInt64LE(8); - logger.debug(`Found improved offer for ${offerAmount}`); - return offerAmount; - } else { - return null; - } -} diff --git a/solana/ts/scripts/improveAuctionOfferAndExecute/app.ts b/solana/ts/scripts/improveAuctionOfferAndExecute/app.ts deleted file mode 100644 index c83d707..0000000 --- a/solana/ts/scripts/improveAuctionOfferAndExecute/app.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Connection, PublicKey } from "@solana/web3.js"; -import "dotenv/config"; -import { AuctionParticipant } from "./AuctionParticipant"; -import * as utils from "../utils"; -import { MatchingEngineProgram } from "../../src/matchingEngine"; - -const MATCHING_ENGINE_PROGRAM_ID = "mPydpGUWxzERTNpyvTKdvS7v8kvw5sgwfiP8WQFrXVS"; -const USDC_MINT = new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); - -main(process.argv); - -async function main(argv: string[]) { - // TODO: read config - const logicLogger = utils.defaultLogger({ label: "logic", level: "debug" }); - logicLogger.info("Start logging logic"); - - if (process.env.SOLANA_SUB_RPC === undefined) { - throw new Error("SOLANA_SUB_RPC is undefined"); - } - if (process.env.SOLANA_REQ_RPC === undefined) { - throw new Error("SOLANA_REQ_RPC is undefined"); - } - - const connection = new Connection(process.env.SOLANA_REQ_RPC, { - commitment: "confirmed", - //wsEndpoint: process.env.SOLANA_SUB_WS, - wsEndpoint: "wss://api.devnet.solana.com/", - }); - const matchingEngine = new MatchingEngineProgram( - connection, - MATCHING_ENGINE_PROGRAM_ID, - USDC_MINT, - ); - - const recognizedPayers = ["2SiZZ6cUrrjCjQdTrBjW5qv6jLdCzfCP4aDeAB3AKXWM"]; - const participant = new AuctionParticipant(matchingEngine, recognizedPayers, logicLogger); - - connection.onLogs(matchingEngine.ID, await participant.onLogsCallback()); -} diff --git a/solana/ts/scripts/setUpTestnetMatchingEngine.ts b/solana/ts/scripts/setUpTestnetMatchingEngine.ts index 1edf2c3..5d2ba1a 100644 --- a/solana/ts/scripts/setUpTestnetMatchingEngine.ts +++ b/solana/ts/scripts/setUpTestnetMatchingEngine.ts @@ -40,7 +40,7 @@ async function main() { if (process.env.SOLANA_PRIVATE_KEY === undefined) { throw new Error("SOLANA_PRIVATE_KEY is undefined"); } - const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "hex")); + const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "base64")); // Set up program. await intialize(matchingEngine, payer); diff --git a/solana/ts/src/matchingEngine/index.ts b/solana/ts/src/matchingEngine/index.ts index 862d17d..28fa2db 100644 --- a/solana/ts/src/matchingEngine/index.ts +++ b/solana/ts/src/matchingEngine/index.ts @@ -1046,7 +1046,6 @@ export class MatchingEngineProgram { let { totalDeposit } = args; offerToken ??= await splToken.getAssociatedTokenAddress(this.mint, payer); - let fetchedConfigId: Uint64 | null = null; if ( auction === undefined || @@ -1057,7 +1056,6 @@ export class MatchingEngineProgram { const vaaAccount = await VaaAccount.fetch(this.program.provider.connection, fastVaa); auction ??= this.auctionAddress(vaaAccount.digest()); fromRouterEndpoint ??= this.routerEndpointAddress(vaaAccount.emitterInfo().chain); - const { fastMarketOrder } = LiquidityLayerMessage.decode(vaaAccount.payload()); if (fastMarketOrder === undefined) { throw new Error("Message not FastMarketOrder"); @@ -1084,7 +1082,6 @@ export class MatchingEngineProgram { } const auctionCustodyToken = this.auctionCustodyTokenAddress(auction); - const { transferAuthority, ix: approveIx } = await this.approveTransferAuthorityIx( { auction, owner: payer }, { @@ -1267,15 +1264,16 @@ export class MatchingEngineProgram { executor: PublicKey; fastVaa: PublicKey; finalizedVaa: PublicKey; - bestOfferToken?: PublicKey; - auction?: PublicKey; + bestOfferToken: PublicKey; + auction: PublicKey; }, args: CctpMessageArgs, signers: Signer[], opts: PreparedTransactionOptions, confirmOptions?: ConfirmOptions, ): Promise { - const { executor, fastVaa, finalizedVaa, auction, bestOfferToken } = accounts; + let { executor, fastVaa, finalizedVaa, auction, bestOfferToken } = accounts; + const prepareOrderResponseIx = await this.prepareOrderResponseCctpIx( { payer: executor, fastVaa, finalizedVaa }, args, @@ -1285,6 +1283,16 @@ export class MatchingEngineProgram { // Fetch the prepared order response. const preparedOrderResponse = this.preparedOrderResponseAddress(fastVaaAccount.digest()); + // The executor must be the owner of the best offer token if there is no penalty. + const { status } = await this.fetchAuction({ address: auction }); + if (status.completed?.executePenalty === null) { + const { owner } = await splToken.getAccount( + this.program.provider.connection, + bestOfferToken, + ); + executor = owner; + } + const settleAuctionCompletedIx = await this.settleAuctionCompleteIx({ executor, auction, diff --git a/solana/ts/src/matchingEngine/state/PreparedOrderResponse.ts b/solana/ts/src/matchingEngine/state/PreparedOrderResponse.ts index a3820eb..b6876b2 100644 --- a/solana/ts/src/matchingEngine/state/PreparedOrderResponse.ts +++ b/solana/ts/src/matchingEngine/state/PreparedOrderResponse.ts @@ -1,7 +1,6 @@ import { BN } from "@coral-xyz/anchor"; import { PublicKey } from "@solana/web3.js"; import { VaaHash } from "../../common"; - export class PreparedOrderResponse { bump: number; preparedBy: PublicKey; diff --git a/solana/ts/tests/01__matchingEngine.ts b/solana/ts/tests/01__matchingEngine.ts index be4da61..c147871 100644 --- a/solana/ts/tests/01__matchingEngine.ts +++ b/solana/ts/tests/01__matchingEngine.ts @@ -3270,6 +3270,7 @@ describe("Matching Engine", function () { auction, fastVaa, finalizedVaa: finalized!.vaa, + bestOfferToken: initialData.info!.bestOfferToken, }, finalized!.cctp, [playerTwo],