diff --git a/bin/delegate-account b/bin/delegate-account new file mode 100755 index 0000000..8f5b1a4 --- /dev/null +++ b/bin/delegate-account @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +import argparse +import os +import os.path +import sys + +from solana.publickey import PublicKey + +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), ".."))) +import mango # nopep8 + +parser = argparse.ArgumentParser(description="Delegate operational authority of a Mango account to another account.") +mango.ContextBuilder.add_command_line_parameters(parser) +mango.Wallet.add_command_line_parameters(parser) +parser.add_argument("--account-address", type=PublicKey, required=True, + help="address of the Mango account to delegate") +parser.add_argument("--delegate-address", type=PublicKey, required=False, + help="address of the account to which to delegate operational control of the Mango account") +parser.add_argument("--revoke", action="store_true", default=False, + help="revoke any previous account delegation") +args: argparse.Namespace = mango.parse_args(parser) + +context = mango.ContextBuilder.from_command_line_parameters(args) + +wallet = mango.Wallet.from_command_line_parameters_or_raise(args) + +group = mango.Group.load(context, context.group_address) +account: mango.Account = mango.Account.load(context, args.account_address, group) +if account.owner != wallet.address: + raise Exception(f"Account {account.address} is not owned by current wallet {wallet.address}.") + +all_instructions: mango.CombinableInstructions = mango.CombinableInstructions.from_signers([wallet.keypair]) +if args.revoke: + unset_delegate_instructions = mango.build_unset_account_delegate_instructions(context, wallet, group, account) + all_instructions += unset_delegate_instructions +else: + if args.delegate_address is None: + raise Exception("No delegate address specified") + + set_delegate_instructions = mango.build_set_account_delegate_instructions( + context, wallet, group, account, args.delegate_address) + all_instructions += set_delegate_instructions + +transaction_ids = all_instructions.execute(context) +mango.output(f"Transaction IDs: {transaction_ids}") diff --git a/bin/deposit b/bin/deposit index 6482316..7089581 100755 --- a/bin/deposit +++ b/bin/deposit @@ -6,6 +6,7 @@ import os.path import sys from decimal import Decimal +from solana.publickey import PublicKey sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), '..'))) @@ -16,18 +17,15 @@ mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) parser.add_argument("--symbol", type=str, required=True, help="token symbol to deposit (e.g. USDC)") parser.add_argument("--quantity", type=Decimal, required=True, help="quantity token to deposit") -parser.add_argument("--account-index", type=int, default=0, - help="index of the account to use, if more than one available") +parser.add_argument("--account-address", type=PublicKey, + help="address of the specific account to use, if more than one available") args: argparse.Namespace = mango.parse_args(parser) context = mango.ContextBuilder.from_command_line_parameters(args) wallet = mango.Wallet.from_command_line_parameters_or_raise(args) group = mango.Group.load(context, context.group_address) -accounts = mango.Account.load_all_for_owner(context, wallet.address, group) -if len(accounts) == 0: - raise Exception(f"Could not find any Mango accounts for '{wallet.address}'.") -account = accounts[args.account_index] +account = mango.Account.load_for_owner_by_address(context, wallet.address, group, args.account_address) instrument = context.instrument_lookup.find_by_symbol(args.symbol) if instrument is None: diff --git a/bin/ensure-open-orders b/bin/ensure-open-orders index 5da0d69..005aacf 100755 --- a/bin/ensure-open-orders +++ b/bin/ensure-open-orders @@ -5,6 +5,8 @@ import os import os.path import sys +from solana.publickey import PublicKey + sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), '..'))) import mango # nopep8 @@ -12,9 +14,9 @@ import mango # nopep8 parser = argparse.ArgumentParser(description="Ensure an OpenOrders account exists for the wallet and market.") mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) -parser.add_argument("--account-index", type=int, default=0, - help="index of the account to use, if more than one available") parser.add_argument("--market", type=str, required=True, help="market symbol to buy (e.g. ETH/USDC)") +parser.add_argument("--account-address", type=PublicKey, + help="address of the specific account to use, if more than one available") parser.add_argument("--dry-run", action="store_true", default=False, help="runs as read-only and does not perform any transactions") args: argparse.Namespace = mango.parse_args(parser) @@ -23,10 +25,7 @@ context = mango.ContextBuilder.from_command_line_parameters(args) wallet = mango.Wallet.from_command_line_parameters_or_raise(args) group = mango.Group.load(context) -accounts = mango.Account.load_all_for_owner(context, wallet.address, group) -if len(accounts) == 0: - raise Exception(f"No Mango account exists for group {group.address} and wallet {wallet.address}") -account = accounts[args.account_index] +account = mango.Account.load_for_owner_by_address(context, wallet.address, group, args.account_address) market = context.market_lookup.find_by_symbol(args.market) if market is None: diff --git a/bin/show-accounts b/bin/show-accounts index c415da9..a8d8361 100755 --- a/bin/show-accounts +++ b/bin/show-accounts @@ -11,7 +11,7 @@ sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), ".."))) import mango # nopep8 -parser = argparse.ArgumentParser(description="Shows details of a Merps account.") +parser = argparse.ArgumentParser(description="Shows details of a Mango account.") mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) parser.add_argument("--address", type=PublicKey, required=False, diff --git a/bin/show-delegated-accounts b/bin/show-delegated-accounts new file mode 100755 index 0000000..6a83561 --- /dev/null +++ b/bin/show-delegated-accounts @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +import argparse +import os +import os.path +import sys + +from solana.publickey import PublicKey + +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), ".."))) +import mango # nopep8 + +parser = argparse.ArgumentParser(description="Shows details of a Mango account.") +mango.ContextBuilder.add_command_line_parameters(parser) +mango.Wallet.add_command_line_parameters(parser) +parser.add_argument("--address", type=PublicKey, required=False, + help="address of the delegate of the account (defaults to the root address of the wallet)") +args: argparse.Namespace = mango.parse_args(parser) + +context = mango.ContextBuilder.from_command_line_parameters(args) + +delegate = args.address +if delegate is None: + wallet = mango.Wallet.from_command_line_parameters_or_raise(args) + delegate = wallet.address + +group = mango.Group.load(context, context.group_address) +mango_accounts = mango.Account.load_all_for_delegate(context, delegate, group) +if len(mango_accounts) == 0: + mango.output(f"Account {delegate} has no accounts delegated to it.") +else: + mango.output(mango_accounts) diff --git a/bin/show-open-orders b/bin/show-open-orders index 97bcc83..fb47e71 100755 --- a/bin/show-open-orders +++ b/bin/show-open-orders @@ -17,8 +17,8 @@ mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) parser.add_argument("--address", type=PublicKey, help="Root address to check (if not provided, the wallet address is used)") -parser.add_argument("--account-index", type=int, default=0, - help="index of the account to use, if more than one available") +parser.add_argument("--account-address", type=PublicKey, + help="address of the specific account to use, if more than one available") args: argparse.Namespace = mango.parse_args(parser) context = mango.ContextBuilder.from_command_line_parameters(args) @@ -28,8 +28,7 @@ if address is None: address = wallet.address group = mango.Group.load(context) -accounts = mango.Account.load_all_for_owner(context, address, group) -account = accounts[args.account_index] +account = mango.Account.load_for_owner_by_address(context, wallet.address, group, args.account_address) at_least_one_open_orders_account = False quote_token_bank = group.shared_quote_token diff --git a/bin/withdraw b/bin/withdraw index d4cb214..4d64435 100755 --- a/bin/withdraw +++ b/bin/withdraw @@ -6,6 +6,7 @@ import os.path import sys from decimal import Decimal +from solana.publickey import PublicKey sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), '..'))) @@ -16,8 +17,8 @@ mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) parser.add_argument("--symbol", type=str, required=True, help="token symbol to withdraw (e.g. USDC)") parser.add_argument("--quantity", type=Decimal, required=True, help="quantity token to withdraw") -parser.add_argument("--account-index", type=int, default=0, - help="index of the account to use, if more than one available") +parser.add_argument("--account-address", type=PublicKey, + help="address of the specific account to use, if more than one available") parser.add_argument("--allow-borrow", action="store_true", default=False, help="allow borrowing to fund the withdrawal") args: argparse.Namespace = mango.parse_args(parser) @@ -26,10 +27,7 @@ context = mango.ContextBuilder.from_command_line_parameters(args) wallet = mango.Wallet.from_command_line_parameters_or_raise(args) group = mango.Group.load(context, context.group_address) -accounts = mango.Account.load_all_for_owner(context, wallet.address, group) -if len(accounts) == 0: - raise Exception(f"Could not find any margin accounts for '{wallet.address}'.") -account = accounts[args.account_index] +account = mango.Account.load_for_owner_by_address(context, wallet.address, group, args.account_address) instrument = context.instrument_lookup.find_by_symbol(args.symbol) if instrument is None: diff --git a/docs/Delegation.md b/docs/Delegation.md new file mode 100644 index 0000000..61ae0f6 --- /dev/null +++ b/docs/Delegation.md @@ -0,0 +1,173 @@ +# 🥭 Mango Explorer + +# 🔥 Delegation + +Mango Account Delegation is a feature that allows a separate account limited access to the Mango Account's features. + +Delegation can provide some additional security for more sophisticated key management procedures. + +A delegated account *can*, for example: +* Deposit funds +* Place orders +* Cancel orders + +A delegated account *cannot*, for example: +* Withdraw funds +* Close the Mango Account + +A Mango Account can have at most one delegate. The owner of the Mango Account continues to have full control of the Mango Account irrespective of delegation. + + +# 🆒 Commands + +`mango-explorer` provides two commands for delegation: `delegate-account` and `show-delegated-accounts`. + +You can see exactly what additional parameters a command takes by specifying the `--help` parameter. + +## `delegate-account` + +> Accepts parameter: `--account-address ` (required, `PublicKey`, no default) + +> Accepts parameter: `--delegate-address ` (optional, `PublicKey`, no default) + +> Accepts parameter: `--revoke` (optional, TRUE if specified otherwise FALSE) + +`delegate-account` allows you to set or revoke delegation of a Mango Account. It must be run by the owner of the Mango Account. + + +## `show-delegated-accounts` + +> Accepts parameter: `--address ` (optional, `PublicKey`, default value: wallet root address) + +`show-delegated-accounts` will list all accounts for which the current wallet is nominated as the delegate. **It will not** list accounts *owned* by the current wallet, only those in which it is listed as a delegate. + + +# ✅ Example Walkthrough + +To run this walkthrough, you need to set up 2 different Solana wallets and 1 Mango Account. Choose one of the wallets as the Owner and use it to create a Mango Account. The other wallet is the Delegate. + +In these examples, +* `ALBdwdFnHsmTAQ3xGqkDK8hcTMXeuMNyCNkz1ikBzv3L` is the address of the Mango Account Owner's wallet +* `7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62` is the address of the Mango Account +* `6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB` is the address of Delegate's wallet + + +## Show No Delegate + +*Run as Owner of the Mango Account* + +This command will show the Mango Account, including what account (if any) is listed as a delegate: +``` +show-accounts +``` +This will output something like (abridged): +``` +« Account (un-named), Version.V3 [7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62] + « Metadata Version.V2 - Account: Initialized » + Owner: ALBdwdFnHsmTAQ3xGqkDK8hcTMXeuMNyCNkz1ikBzv3L + Delegated To: None +``` + + +## Delegate Account + +*Run as Owner of the Mango Account* + +This command will grant the Delegate limited access to the Mango Account: +``` +delegate-account --account-address 7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62 --delegate-address 6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB +``` + + +## Show Delegate + +*Run as Owner of the Mango Account* + +This command will now show the Mango Account with `6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB` listed as a delegate: +``` +show-accounts +``` +This will output the address of the Delegate in the 'Delegated To' line (abridged): +``` +« Account (un-named), Version.V3 [7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62] + « Metadata Version.V2 - Account: Initialized » + Owner: ALBdwdFnHsmTAQ3xGqkDK8hcTMXeuMNyCNkz1ikBzv3L + Delegated To: 6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB +``` + + +## Show Delegated Account + +*Run as Delegate* + +This command will show all Mango Accounts with the current user listed as a delegate. If it is run as the user with the wallet address `6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB` (the Delegate) +``` +show-delegated-accounts +``` +It will show the same output as `show-account` run by the Owner: +``` +« Account (un-named), Version.V3 [7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62] + « Metadata Version.V2 - Account: Initialized » + Owner: ALBdwdFnHsmTAQ3xGqkDK8hcTMXeuMNyCNkz1ikBzv3L + Delegated To: 6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB +``` + + +## Deposit + +*Run as Delegate* + +Delegates can deposit funds from their own wallet (not the Owner's wallet). +``` +deposit --symbol SOL --quantity 1 --account-address 7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62 +``` +Note that to target an account which has been delegated, the `--account-address` parameter must be specified. (This is the same way an account is specified for wallets that hold multiple Mango Accounts.) + + +## Place Order + +*Run as Delegate* + +Delegates can place and cancel orders using the delegated Mango Account. +``` +place-order --market SOL-PERP --quantity 1 --side BUY --order-type LIMIT --price 10 --skip-preflight --account-address 7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62 +``` + +**Note**: it is the Delegate running this command so it is the Delegate that pays the SOL for the transaction. + + +## Withdraw + +*Run as Delegate* + +Delegates **cannot** withdraw funds from the delegated Mango Account. **This will fail**: +``` +withdraw --symbol SOL --quantity 1 --account-address 7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62 +``` +**This will fail** with an error containing the following in the transaction logs: +``` +Program log: MangoErrorCode::InvalidOwner; src/processor.rs +``` + +## Revoke Delegation + +*Run as Owner of the Mango Account* + +This command will revoke the delegated authority to the Delegate, removing their access to the Mango Account: +``` +delegate-account --account-address 7zTdp1YhPdBQw8nFJJXkmTewZf4zPwDbjktKzQLaQr62 --revoke +``` + + +## Show No Delegated Accounts + +*Run as Delegate* + +Running the same command as earlier: +``` +show-delegated-accounts +``` +Now shows no delegated accounts, since the delegation was revoked: +``` +Account 6aNN6nbAm9Dbqex9kKGAYcMFJg7Jhertwew9JhRv4QfB has no accounts delegated to it. +``` diff --git a/mango/__init__.py b/mango/__init__.py index aa6285e..141c4fa 100644 --- a/mango/__init__.py +++ b/mango/__init__.py @@ -88,8 +88,10 @@ from .instructions import build_redeem_accrued_mango_instructions as build_redee from .instructions import build_serum_consume_events_instructions as build_serum_consume_events_instructions from .instructions import build_serum_place_order_instructions as build_serum_place_order_instructions from .instructions import build_serum_settle_instructions as build_serum_settle_instructions +from .instructions import build_set_account_delegate_instructions as build_set_account_delegate_instructions from .instructions import build_spot_place_order_instructions as build_spot_place_order_instructions from .instructions import build_transfer_spl_tokens_instructions as build_transfer_spl_tokens_instructions +from .instructions import build_unset_account_delegate_instructions as build_unset_account_delegate_instructions from .instructions import build_withdraw_instructions as build_withdraw_instructions from .instructionreporter import InstructionReporter as InstructionReporter from .instructionreporter import SerumInstructionReporter as SerumInstructionReporter diff --git a/mango/account.py b/mango/account.py index 632a20f..65811bd 100644 --- a/mango/account.py +++ b/mango/account.py @@ -344,6 +344,34 @@ class Account(AddressableAccount): accounts += [account] return accounts + @staticmethod + def load_all_for_delegate(context: Context, delegate: PublicKey, group: Group) -> typing.Sequence["Account"]: + # mango_group is just after the METADATA, which is the first entry. + group_offset = layouts.METADATA.sizeof() + # delegate is a PublicKey which is 32 bytes that ends 5 bytes before the end of the layout + delegate_offset = layouts.MANGO_ACCOUNT.sizeof() - 37 + filters = [ + MemcmpOpts( + offset=group_offset, + bytes=encode_key(group.address) + ), + MemcmpOpts( + offset=delegate_offset, + bytes=encode_key(delegate) + ) + ] + + results = context.client.get_program_accounts( + context.mango_program_address, memcmp_opts=filters, data_size=layouts.MANGO_ACCOUNT.sizeof()) + cache: Cache = group.fetch_cache(context) + accounts: typing.List[Account] = [] + for account_data in results: + address = PublicKey(account_data["pubkey"]) + account_info = AccountInfo._from_response_values(account_data["account"], address) + account = Account.parse(account_info, group, cache) + accounts += [account] + return accounts + @staticmethod def load_for_owner_by_address(context: Context, owner: PublicKey, group: Group, account_address: typing.Optional[PublicKey]) -> "Account": if account_address is not None: diff --git a/mango/instructions.py b/mango/instructions.py index 852d80f..f41da77 100644 --- a/mango/instructions.py +++ b/mango/instructions.py @@ -879,3 +879,37 @@ def build_faucet_airdrop_instructions(token_mint: PublicKey, destination: Public }) ) return CombinableInstructions(signers=[], instructions=[faucet_airdrop_instruction]) + + +# # 🥭 build_set_account_delegate_instructions function +# +# Creates an instruction to delegate account operations (except Withdraw and CloseAccount) to a +# different account. +# +# Set to SYSTEM_PROGRAM_ADDRESS to revoke delegate. +# +def build_set_account_delegate_instructions(context: Context, wallet: Wallet, group: Group, account: Account, delegate: PublicKey) -> CombinableInstructions: + # /// https://github.com/blockworks-foundation/mango-v3/pull/97/ + # /// Set delegate authority to mango account which can do everything regular account can do + # /// except Withdraw and CloseMangoAccount. Set to Pubkey::default() to revoke delegate + # /// + # /// Accounts expected: 4 + # /// 0. `[]` mango_group_ai - MangoGroup + # /// 1. `[writable]` mango_account_ai - MangoAccount + # /// 2. `[signer]` owner_ai - Owner of Mango Account + # /// 3. `[]` delegate_ai - delegate + set_delegate_instruction = TransactionInstruction( + keys=[ + AccountMeta(is_signer=False, is_writable=False, pubkey=group.address), + AccountMeta(is_signer=False, is_writable=True, pubkey=account.address), + AccountMeta(is_signer=True, is_writable=False, pubkey=wallet.address), + AccountMeta(is_signer=False, is_writable=False, pubkey=delegate) + ], + program_id=context.mango_program_address, + data=layouts.SET_DELEGATE.build({}) + ) + return CombinableInstructions(signers=[], instructions=[set_delegate_instruction]) + + +def build_unset_account_delegate_instructions(context: Context, wallet: Wallet, group: Group, account: Account) -> CombinableInstructions: + return build_set_account_delegate_instructions(context, wallet, group, account, SYSTEM_PROGRAM_ADDRESS) diff --git a/mango/layouts/layouts.py b/mango/layouts/layouts.py index 5950698..e22412a 100644 --- a/mango/layouts/layouts.py +++ b/mango/layouts/layouts.py @@ -1570,6 +1570,20 @@ CREATE_MANGO_ACCOUNT = construct.Struct( ) +# /// https://github.com/blockworks-foundation/mango-v3/pull/97/ +# /// Set delegate authority to mango account which can do everything regular account can do +# /// except Withdraw and CloseMangoAccount. Set to Pubkey::default() to revoke delegate +# /// +# /// Accounts expected: 4 +# /// 0. `[]` mango_group_ai - MangoGroup +# /// 1. `[writable]` mango_account_ai - MangoAccount +# /// 2. `[signer]` owner_ai - Owner of Mango Account +# /// 3. `[]` delegate_ai - delegate +SET_DELEGATE = construct.Struct( + "variant" / construct.Const(58, construct.BytesInteger(4, swapped=True)) +) + + UNSPECIFIED = construct.Struct( "variant" / DecimalAdapter(4) ) @@ -1633,7 +1647,7 @@ InstructionParsersByVariant = { 55: CREATE_MANGO_ACCOUNT, # CREATE_MANGO_ACCOUNT, 56: UNSPECIFIED, # UPGRADE_MANGO_ACCOUNT_V0_V1, 57: UNSPECIFIED, # CANCEL_PERP_ORDER_SIDE, - 58: UNSPECIFIED, # SET_DELEGATE, + 58: SET_DELEGATE, # SET_DELEGATE, 59: UNSPECIFIED, # CHANGE_SPOT_MARKET_PARAMS, 60: UNSPECIFIED, # CREATE_SPOT_OPEN_ORDERS, }