Browse Source

RUNE user-level transaction support

Transaction sends and balance queries are done via the remote Thornode server
at ninerealms.com, so it’s recommended to proxy them via Tor, as shown below.

Sample create-send-sign workflow (assumes `autosign` set in cfg file):

    $ mmgen-txcreate --proxy=localhost:9050 -q --coin=rune 12ABCDEF:X:2,1.234

    remove device - insert - wait for signing - remove - insert

    $ mmgen-txsend --proxy=localhost:9050 -q

Check/refresh tracking wallet after sending:

    $ mmgen-tool --proxy=localhost:9050 --coin=rune twview interactive=1

Testing/demo:

    $ pip install pure-protobuf

    $ test/cmdtest.py --coin=rune --demo rune
The MMGen Project 5 months ago
parent
commit
ef76cf6460

+ 2 - 1
mmgen/cfg.py

@@ -51,8 +51,9 @@ class GlobalConstants(Lockable):
 	min_time_precision = 18
 
 	# must match CoinProtocol.coins
-	core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr')
+	core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr', 'rune')
 	rpc_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'xmr')
+	remote_rpc_coins = ('rune',)
 	btc_fork_rpc_coins = ('btc', 'bch', 'ltc')
 	eth_fork_coins = ('eth', 'etc')
 

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev43
+15.1.dev44

+ 6 - 3
mmgen/main_txcreate.py

@@ -44,7 +44,7 @@ opts_data = {
 			-- -a, --autosign        Create a transaction for offline autosigning (see
 			+                        ‘mmgen-autosign’). The removable device is mounted and
 			+                        unmounted automatically
-			-- -A, --fee-adjust=  f  Adjust transaction fee by factor 'f' (see below)
+			L- -A, --fee-adjust=  f  Adjust transaction fee by factor ‘f’ (see below)
 			-- -B, --no-blank        Don't blank screen before displaying {a_info}
 			-- -c, --comment-file=f  Source the transaction's comment from file 'f'
 			b- -C, --fee-estimate-confs=c Desired number of confirmations for fee estimation
@@ -53,7 +53,7 @@ opts_data = {
 			e- -D, --contract-data=D Path to file containing hex-encoded contract data
 			b- -E, --fee-estimate-mode=M Specify the network fee estimate mode.  Choices:
 			+                        {fe_all}.  Default: {fe_dfl!r}
-			-- -f, --fee=         f  Transaction fee, as a decimal {cu} amount or as
+			L- -f, --fee=         f  Transaction fee, as a decimal {cu} amount or as
 			+                        {fu} (an integer followed by {fl}).
 			+                        See FEE SPECIFICATION below.  If omitted, fee will be
 			+                        calculated using network fee estimation.
@@ -81,9 +81,12 @@ opts_data = {
 			-s -S, --list-assets     List available swap assets
 			-- -v, --verbose         Produce more verbose output
 			b- -V, --vsize-adj=   f  Adjust transaction's estimated vsize by factor 'f'
-			-s -x, --proxy=P         Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port).
+			Ls -x, --proxy=P         Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port).
 			+                        Use special value ‘env’ to honor *_PROXY environment
 			+                        vars instead.
+			X- -x, --proxy=P         Connect to remote server(s) via SOCKS5h proxy ‘P’
+			+                        (host:port).  Use special value ‘env’ to honor *_PROXY
+			+                        environment vars instead.
 			-- -y, --yes             Answer 'yes' to prompts, suppress non-essential output
 			e- -X, --cached-balances Use cached balances
 		""",

+ 6 - 3
mmgen/main_txdo.py

@@ -41,7 +41,7 @@ opts_data = {
 		'options': """
 			-- -h, --help             Print this help message
 			-- --, --longhelp         Print help message for long (global) options
-			-- -A, --fee-adjust=    f Adjust transaction fee by factor 'f' (see below)
+			L- -A, --fee-adjust=    f Adjust transaction fee by factor ‘f’ (see below)
 			-- -b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for
 			+                         brainwallet input
 			-- -B, --no-blank         Don't blank screen before displaying {a_info}
@@ -53,7 +53,7 @@ opts_data = {
 			-- -e, --echo-passphrase  Print passphrase to screen when typing it
 			b- -E, --fee-estimate-mode=M Specify the network fee estimate mode.  Choices:
 			+                         {fe_all}.  Default: {fe_dfl!r}
-			-- -f, --fee=           f Transaction fee, as a decimal {cu} amount or as
+			L- -f, --fee=           f Transaction fee, as a decimal {cu} amount or as
 			+                         {fu} (an integer followed by {fl}).
 			+                         See FEE SPECIFICATION below.  If omitted, fee will be
 			+                         calculated using network fee estimation.
@@ -107,9 +107,12 @@ opts_data = {
 			-- -v, --verbose          Produce more verbose output
 			b- -V, --vsize-adj=     f Adjust transaction's estimated vsize by factor 'f'
 			e- -w, --wait             Wait for transaction confirmation
-			-s -x, --proxy=P          Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port).
+			Ls -x, --proxy=P          Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port).
 			+                         Use special value ‘env’ to honor *_PROXY environment
 			+                         vars instead.
+			X- -x, --proxy=P          Connect to remote server(s) via SOCKS5h proxy ‘P’
+			+                         (host:port).  Use special value ‘env’ to honor *_PROXY
+			+                         environment vars instead.
 			e- -X, --cached-balances  Use cached balances
 			-- -y, --yes              Answer 'yes' to prompts, suppress non-essential output
 			-- -z, --show-hash-presets Show information on available hash presets

+ 7 - 4
mmgen/opts.py

@@ -358,6 +358,8 @@ class UserOpts(Opts):
 		  'e' - Ethereum or Ethereum code fork
 		  'r' - coin supporting RPC
 		  'h' - Bitcoin Cash
+		  'L' - local RPC coin
+		  'X' - remote RPC coin
 		  '-' - other coin
 		Cmd codes:
 		  'p' - proto required
@@ -372,10 +374,11 @@ class UserOpts(Opts):
 			return ret(
 				coin = caps.coin_codes or (
 					None if coin is None else
-					['-', 'r', 'R', 'b', 'h'] if coin == 'bch' else
-					['-', 'r', 'R', 'b'] if coin in gc.btc_fork_rpc_coins else
-					['-', 'r', 'R', 'e'] if coin in gc.eth_fork_coins else
-					['-', 'r'] if coin in gc.rpc_coins else
+					['-', 'r', 'R', 'b', 'h', 'L'] if coin == 'bch' else
+					['-', 'r', 'R', 'b', 'L'] if coin in gc.btc_fork_rpc_coins else
+					['-', 'r', 'R', 'e', 'L'] if coin in gc.eth_fork_coins else
+					['-', 'r', 'L'] if coin in gc.rpc_coins else
+					['-', 'X'] if coin in gc.remote_rpc_coins else
 					['-']),
 				cmd = (
 					['-']

+ 1 - 1
mmgen/proto/rune/params.py

@@ -27,7 +27,7 @@ class mainnet(CoinProtocol.Secp256k1):
 	preferred_mmtypes  = ('X',)
 	dfl_mmtype      = 'X'
 	coin_amt        = 'UniAmt'
-	max_tx_fee      = 1 # TODO
+	max_tx_fee      = 0.1
 	caps            = ()
 	mmcaps          = ('tw', 'rpc', 'rpc_init')
 	base_proto      = 'THORChain'

+ 1 - 6
mmgen/proto/rune/tw/view.py

@@ -13,9 +13,4 @@ proto.rune.tw.view: THORChain protocol base class for tracking wallet view class
 """
 
 class THORChainTwView:
-
-	def gen_subheader(self, cw, color):
-		yield from super().gen_subheader(cw, color)
-		if self.proto.network == 'mainnet':
-			from ....color import red
-			yield red('For demonstration purposes only! DO NOT SPEND to these addresses!') # TODO
+	pass

+ 60 - 0
mmgen/proto/rune/tx/base.py

@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.base: THORChain base transaction class
+"""
+
+from ....tx.base import Base as TxBase
+
+class Base(TxBase):
+
+	dfl_fee = 2000000
+	dfl_gas = 800000
+	usr_contract_data = None
+	disable_fee_check = False
+
+	@property
+	def nondata_outputs(self):
+		return self.outputs
+
+	@property
+	def usr_fee(self):
+		return self.proto.coin_amt(self.dfl_fee, from_unit='satoshi')
+
+	def add_blockcount(self):
+		pass
+
+	def is_replaceable(self):
+		return False
+
+	def check_serialized_integrity(self):
+		if self.signed:
+			from .protobuf import RuneTx
+			tx = RuneTx.loads(bytes.fromhex(self.serialized))
+
+			o = self.txobj
+			b = tx.body.messages[0].body
+
+			if s := self.proto.encode_addr_bech32x(b.fromAddress) != o['from']:
+				raise ValueError(f'{s}: invalid ‘from’ address in serialized data')
+
+			if s := self.proto.encode_addr_bech32x(b.toAddress) != o['to']:
+				raise ValueError(f'{s}: invalid ‘to’ address in serialized data')
+
+			if d := self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi') != o['amt']:
+				raise ValueError(f'{d}: invalid send amount in serialized data')
+
+			if n := tx.authInfo.signerInfos[0].sequence != o['sequence']:
+				raise ValueError(f'{n}: invalid sequence number in serialized data')
+
+			if self.is_swap:
+				if b.memo != self.swap_memo.encode():
+					raise ValueError(f'{b.memo}: invalid swap memo in serialized data')

+ 29 - 0
mmgen/proto/rune/tx/completed.py

@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.completed: THORChain completed transaction class
+"""
+
+from ....tx import completed as TxBase
+
+from ...vm.tx.completed import Completed as VmCompleted
+
+from .base import Base
+
+class Completed(VmCompleted, Base, TxBase.Completed):
+
+	@property
+	def total_gas(self):
+		return self.txobj['gas']
+
+	@property
+	def fee(self):
+		return self.proto.coin_amt(self.dfl_fee, from_unit='satoshi')

+ 49 - 0
mmgen/proto/rune/tx/info.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.info: THORChain transaction info class
+"""
+
+from ....tx.info import TxInfo, mmid_disp
+from ....color import blue, pink
+from ....obj import NonNegativeInt
+
+class TxInfo(TxInfo):
+
+	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, *, terse, sort):
+		tx = self.tx
+		t = tx.txobj
+		fs = """
+			From:      {f}{f_mmid}
+			To:        {t}{t_mmid}
+			Amount:    {a} {c}
+			Gas limit: {G}
+			Sequence:  {N}
+		""".strip().replace('\t', '') + ('\nMemo:      {m}' if tx.is_swap else '')
+		return fs.format(
+			f      = t['from'].hl(0),
+			t      = t['to'].hl(0) if tx.outputs else blue('None'),
+			a      = t['amt'].hl(),
+			N      = NonNegativeInt(t['sequence']).hl(),
+			m      = pink(tx.swap_memo) if tx.is_swap else None,
+			c      = tx.proto.dcoin if tx.outputs else '',
+			G      = NonNegativeInt(tx.total_gas).hl(),
+			f_mmid = mmid_disp(tx.inputs[0], nonmm_str),
+			t_mmid = mmid_disp(tx.outputs[0], nonmm_str) if tx.outputs else '') + '\n\n'
+
+	def format_abs_fee(self, iwidth, /, *, color=None):
+		return self.tx.fee.fmt(iwidth, color=color)
+
+	def format_rel_fee(self):
+		return ''
+
+	def format_verbose_footer(self):
+		return ''

+ 38 - 0
mmgen/proto/rune/tx/new.py

@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.new: THORChain new transaction class
+"""
+
+from ....tx import new as TxBase
+
+from ...vm.tx.new import New as VmNew
+
+from .base import Base
+
+class New(VmNew, Base, TxBase.New):
+
+	async def get_fee(self, fee, outputs_sum, start_fee_desc):
+		return await self.twctl.get_balance(self.inputs[0].addr)
+
+	async def set_gas(self, *, to_addr=None, force=False):
+		self.gas = self.dfl_gas
+
+	async def make_txobj(self): # called by create_serialized()
+		acct_info = self.rpc.get_account_info(self.inputs[0].addr)
+		self.txobj = {
+			'from':           self.inputs[0].addr,
+			'to':             self.outputs[0].addr if self.outputs else None,
+			'amt':            self.outputs[0].amt if self.outputs else self.swap_amt,
+			'gas':            self.gas,
+			'account_number': int(acct_info['account_number']),
+			'sequence':       int(acct_info['sequence']),
+			'chain_id':       self.proto.chain_id}

+ 51 - 0
mmgen/proto/rune/tx/online.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.online: THORChain online signed transaction class
+"""
+
+from ....util import pp_msg, die
+from ....tx import online as TxBase
+
+from .signed import Signed
+
+class OnlineSigned(Signed, TxBase.OnlineSigned):
+
+	async def test_sendable(self, txhex):
+		res = self.rpc.tx_op(bytes.fromhex(txhex), op='check_tx')
+		if res['code'] == 0:
+			return True
+		else:
+			pp_msg(res)
+			return False
+
+	async def send_checks(self):
+		pass
+
+	async def send_with_node(self, txhex):
+		res = self.rpc.tx_op(bytes.fromhex(txhex), op='broadcast_tx_sync') # broadcast_tx_async
+		if res['code'] == 0:
+			return res['hash'].lower()
+		else:
+			pp_msg(res)
+			die(2, 'Transaction send failed')
+
+	async def post_network_send(self, coin_txid):
+		return True
+
+class Sent(TxBase.Sent, OnlineSigned):
+	pass
+
+class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned):
+	pass
+
+class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned):
+	pass

+ 41 - 0
mmgen/proto/rune/tx/signed.py

@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.signed: THORChain signed transaction class
+"""
+
+from ....tx import signed as TxBase
+from ....obj import CoinTxID, NonNegativeInt
+from .completed import Completed
+
+class Signed(Completed, TxBase.Signed):
+
+	desc = 'signed transaction'
+
+	def parse_txfile_serialized_data(self):
+		from .protobuf import RuneTx
+		tx = RuneTx.loads(bytes.fromhex(self.serialized))
+
+		b = tx.body.messages[0].body
+		i = tx.authInfo
+
+		self.txobj = {
+			'from':     self.proto.encode_addr_bech32x(b.fromAddress),
+			'to':       self.proto.encode_addr_bech32x(b.toAddress),
+			'amt':      self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi'),
+			'gas':      NonNegativeInt(i.fee.gasLimit),
+			'sequence': NonNegativeInt(i.signerInfos[0].sequence)}
+
+		txid = CoinTxID(tx.txid)
+		assert txid == self.coin_txid, 'serialized txid doesn’t match txid in MMGen transaction file'
+
+class AutomountSigned(TxBase.AutomountSigned, Signed):
+	pass

+ 54 - 0
mmgen/proto/rune/tx/unsigned.py

@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.rune.tx.unsigned: THORChain unsigned transaction class
+"""
+
+from ....tx import unsigned as TxBase
+from ....obj import CoinTxID, NonNegativeInt
+from ....addr import CoinAddr
+
+from ...vm.tx.unsigned import Unsigned as VmUnsigned
+
+from .completed import Completed
+
+class Unsigned(VmUnsigned, Completed, TxBase.Unsigned):
+
+	def parse_txfile_serialized_data(self):
+		d = self.serialized
+		self.txobj = {
+			'from':           CoinAddr(self.proto, d['from']),
+			'to':             CoinAddr(self.proto, d['to']) if d['to'] else None,
+			'amt':            self.proto.coin_amt(d['amt']),
+			'gas':            NonNegativeInt(d['gas']),
+			'account_number': NonNegativeInt(d['account_number']),
+			'sequence':       NonNegativeInt(d['sequence']),
+			'chain_id':       d['chain_id']}
+
+	async def do_sign(self, o, wif):
+		from .protobuf import build_tx, send_tx_parms
+		tx = build_tx(
+			self.cfg,
+			self.proto,
+			send_tx_parms(
+				o['from'],
+				o['to'],
+				o['amt'],
+				o['gas'],
+				o['account_number'],
+				o['sequence'],
+				wifkey = wif))
+		self.serialized = bytes(tx).hex()
+		self.coin_txid = CoinTxID(tx.txid)
+		tx.verify_sig(self.proto, o['account_number'])
+
+class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
+	pass

+ 1 - 1
mmgen/protocol.py

@@ -47,7 +47,7 @@ class CoinProtocol(MMGenObject):
 		'etc': proto_info('EthereumClassic', 4),
 		'zec': proto_info('Zcash',           2),
 		'xmr': proto_info('Monero',          5),
-		'rune': proto_info('THORChain',      2)
+		'rune': proto_info('THORChain',      4)
 	}
 
 	class Base(Lockable):

+ 13 - 0
test/cmdtest_d/httpd/thornode/rpc.py

@@ -69,6 +69,19 @@ class ThornodeRPCServer(ThornodeServer):
 					'codespace': ''
 				}
 			}
+		elif m := re.search(r'/broadcast_tx_sync$', req_str):
+			assert method == 'POST'
+			txhex = environ['wsgi.input'].read(24).decode().removeprefix('tx=0x').upper()
+			if txhex.startswith('0A540A52'):
+				data = {
+					'result': {
+						'code': 0,
+						'codespace': '',
+						'data': '',
+						'hash': '14463C716CF08A814868DB779156BCD85A1DF8EE49E924900A74482E9DEE132D',
+						'log': ''
+					}
+				}
 		else:
 			raise ValueError(f'‘{req_str}’: malformed query path')
 

+ 46 - 1
test/cmdtest_d/rune.py

@@ -12,7 +12,7 @@
 test.cmdtest_d.rune: THORChain RUNE tests for the cmdtest.py test suite
 """
 
-from .include.common import dfl_sid
+from .include.common import dfl_sid, dfl_words_file
 from .httpd.thornode.rpc import ThornodeRPCServer
 from .ethdev import CmdTestEthdevMethods
 from .base import CmdTestBase
@@ -42,6 +42,10 @@ class CmdTestRune(CmdTestEthdevMethods, CmdTestBase, CmdTestShared):
 			'tracking wallet and transaction operations',
 			('twview',               'viewing unspent outputs in tracking wallet'),
 			('bal_refresh',          'refreshing address balance in tracking wallet'),
+			('txcreate1',            'creating a transaction'),
+			('txsign1',              'signing the transaction'),
+			('txsend1_test',         'testing whether the transaction can be sent'),
+			('txsend1',              'sending the transaction'),
 		),
 	}
 
@@ -81,6 +85,47 @@ class CmdTestRune(CmdTestEthdevMethods, CmdTestBase, CmdTestShared):
 		t.expect(self.menu_prompt, 'q')
 		return t
 
+	def txcreate1(self):
+		t = self.spawn('mmgen-txcreate', self.rune_opts + ['98831F3A:X:2,54.321'])
+		t.expect(self.menu_prompt, 'q')
+		t.expect('spend from: ', '3\n')
+		t.expect('(y/N): ', 'y') # add comment?
+		t.expect('Comment: ', 'RUNE Boy\n')
+		t.expect('view: ', 'y')
+		t.expect('to continue: ', 'z')
+		t.expect('(y/N): ', 'y') # save?
+		t.written_to_file('Unsigned transaction')
+		return t
+
+	def txsign1(self):
+		return self.txsign_ui_common(
+			self.spawn(
+				'mmgen-txsign',
+				self.rune_opts + [self.get_file_with_ext('rawtx'), dfl_words_file],
+				no_passthru_opts = ['coin']),
+			has_label = True)
+
+	def txsend1_test(self):
+		return self._txsend(add_args=['--test'])
+
+	def txsend1(self):
+		return self._txsend()
+
+	def _txsend(self, add_args=[]):
+		t = self.spawn(
+			'mmgen-txsend',
+			self.rune_opts + add_args + [self.get_file_with_ext('sigtx')],
+			no_passthru_opts = ['coin'])
+		t.expect('view: ', 'y')
+		t.expect('to continue: ', 'z')
+		t.expect('(y/N): ', 'n') # edit comment?
+		if add_args == ['--test']:
+			t.expect('can be sent')
+		else:
+			t.expect('to confirm: ', 'YES\n')
+			t.written_to_file('Sent transaction')
+		return t
+
 	def thornode_server_stop(self):
 		return CmdTestSwapMethods._thornode_server_stop(
 			self, attrname='thornode_server', name='thornode server')