Merge remote-tracking branch 'origin/master' into fix-str
This commit is contained in:
commit
15b11032f1
37
README.md
37
README.md
@ -233,7 +233,7 @@ git clone https://github.com/sc0Vu/web3.php.git
|
||||
|
||||
2. Copy web3.php to web3.php/docker/app directory and start container.
|
||||
```
|
||||
cp files docker/app && docker-compose up -d php
|
||||
cp files docker/app && docker-compose up -d php ganache
|
||||
```
|
||||
|
||||
3. Enter php container and install packages.
|
||||
@ -241,11 +241,44 @@ cp files docker/app && docker-compose up -d php
|
||||
docker-compose exec php ash
|
||||
```
|
||||
|
||||
4. Run test script
|
||||
4. Change testHost in `TestCase.php`
|
||||
```
|
||||
/**
|
||||
* testHost
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $testHost = 'http://ganache:8545';
|
||||
```
|
||||
|
||||
5. Run test script
|
||||
```
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
###### Install packages
|
||||
Enter container first
|
||||
```
|
||||
docker-compose exec php ash
|
||||
```
|
||||
|
||||
1. gmp
|
||||
```
|
||||
apk add gmp-dev
|
||||
docker-php-ext-install gmp
|
||||
```
|
||||
|
||||
2. bcmath
|
||||
```
|
||||
docker-php-ext-install bcmath
|
||||
```
|
||||
|
||||
###### Remove extension
|
||||
Move the extension config from `/usr/local/etc/php/conf.d/`
|
||||
```
|
||||
mv /usr/local/etc/php/conf.d/extension-config-name to/directory
|
||||
```
|
||||
|
||||
# API
|
||||
|
||||
Todo.
|
||||
|
@ -13,7 +13,7 @@
|
||||
"guzzlehttp/guzzle": "~6.0",
|
||||
"PHP": "^7.1",
|
||||
"kornrunner/keccak": "~1.0",
|
||||
"phpseclib/phpseclib": "~2.0"
|
||||
"phpseclib/phpseclib": "~2.0.11"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~6.0"
|
||||
|
@ -9,3 +9,10 @@ services:
|
||||
volumes:
|
||||
- ./app:/app
|
||||
tty: true
|
||||
|
||||
ganache:
|
||||
build:
|
||||
context: ./ganache
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8545"
|
||||
|
9
docker/ganache/Dockerfile
Normal file
9
docker/ganache/Dockerfile
Normal file
@ -0,0 +1,9 @@
|
||||
FROM node:9.11.1-alpine
|
||||
|
||||
MAINTAINER Peter Lai <alk03073135@gmail.com>
|
||||
|
||||
RUN npm install -g ganache-cli
|
||||
|
||||
EXPOSE 8545
|
||||
|
||||
CMD ganache-cli -g 0 -l 6000000 --hostname=0.0.0.0
|
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ganache-cli -g 0 -l 0 > /dev/null &
|
||||
ganache-cli -g 0 -l 6000000 > /dev/null &
|
||||
ganachecli_pid=$!
|
||||
echo "Start ganache-cli pid: $ganachecli_pid and sleep 3 seconds"
|
||||
|
||||
|
@ -22,6 +22,7 @@ use Web3\Contracts\Ethabi;
|
||||
use Web3\Contracts\Types\Address;
|
||||
use Web3\Contracts\Types\Boolean;
|
||||
use Web3\Contracts\Types\Bytes;
|
||||
use Web3\Contracts\Types\DynamicBytes;
|
||||
use Web3\Contracts\Types\Integer;
|
||||
use Web3\Contracts\Types\Str;
|
||||
use Web3\Contracts\Types\Uinteger;
|
||||
@ -133,6 +134,7 @@ class Contract
|
||||
'address' => new Address,
|
||||
'bool' => new Boolean,
|
||||
'bytes' => new Bytes,
|
||||
'dynamicBytes' => new DynamicBytes,
|
||||
'int' => new Integer,
|
||||
'string' => new Str,
|
||||
'uint' => new Uinteger,
|
||||
@ -233,6 +235,14 @@ class Contract
|
||||
return $this->events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getToAddress()
|
||||
{
|
||||
return $this->toAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* getConstructor
|
||||
*
|
||||
@ -397,7 +407,6 @@ class Contract
|
||||
if (count($arguments) > 0) {
|
||||
$transaction = $arguments[0];
|
||||
}
|
||||
$transaction['to'] = '';
|
||||
$transaction['data'] = '0x' . $this->bytecode . Utils::stripZero($data);
|
||||
|
||||
$this->eth->sendTransaction($transaction, function ($err, $transaction) use ($callback){
|
||||
@ -423,8 +432,8 @@ class Contract
|
||||
$method = array_splice($arguments, 0, 1)[0];
|
||||
$callback = array_pop($arguments);
|
||||
|
||||
if (!is_string($method) && !isset($this->functions[$method])) {
|
||||
throw new InvalidArgumentException('Please make sure the method is existed.');
|
||||
if (!is_string($method) || !isset($this->functions[$method])) {
|
||||
throw new InvalidArgumentException('Please make sure the method exists.');
|
||||
}
|
||||
$function = $this->functions[$method];
|
||||
|
||||
@ -469,8 +478,8 @@ class Contract
|
||||
$method = array_splice($arguments, 0, 1)[0];
|
||||
$callback = array_pop($arguments);
|
||||
|
||||
if (!is_string($method) && !isset($this->functions[$method])) {
|
||||
throw new InvalidArgumentException('Please make sure the method is existed.');
|
||||
if (!is_string($method) || !isset($this->functions[$method])) {
|
||||
throw new InvalidArgumentException('Please make sure the method exists.');
|
||||
}
|
||||
$function = $this->functions[$method];
|
||||
|
||||
|
@ -249,7 +249,7 @@ class Ethabi
|
||||
$param = mb_strtolower(Utils::stripZero($param));
|
||||
|
||||
for ($i=0; $i<$typesLength; $i++) {
|
||||
if (isset($outputTypes['outputs'][$i]['name'])) {
|
||||
if (isset($outputTypes['outputs'][$i]['name']) && empty($outputTypes['outputs'][$i]['name']) === false) {
|
||||
$result[$outputTypes['outputs'][$i]['name']] = $solidityTypes[$i]->decode($param, $offsets[$i], $types[$i]);
|
||||
} else {
|
||||
$result[$i] = $solidityTypes[$i]->decode($param, $offsets[$i], $types[$i]);
|
||||
@ -280,8 +280,13 @@ class Ethabi
|
||||
$className = $this->types[$match[0]];
|
||||
|
||||
if (call_user_func([$this->types[$match[0]], 'isType'], $type) === false) {
|
||||
// check dynamic bytes
|
||||
if ($match[0] === 'bytes') {
|
||||
$className = $this->types['dynamicBytes'];
|
||||
} else {
|
||||
throw new InvalidArgumentException('Unsupport solidity parameter type: ' . $type);
|
||||
}
|
||||
}
|
||||
$solidityTypes[$key] = $className;
|
||||
}
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ class Address extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/address(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^address(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -35,7 +35,7 @@ class Boolean extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/bool(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^bool(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -36,7 +36,7 @@ class Bytes extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/bytes([0-9]{1,})?(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^bytes([0-9]{1,})(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -90,6 +90,11 @@ class Bytes extends SolidityType implements IType
|
||||
if (empty($checkZero)) {
|
||||
return '0';
|
||||
}
|
||||
if (preg_match('/^bytes([0-9]*)/', $name, $match) === 1) {
|
||||
$size = intval($match[1]);
|
||||
$length = 2 * $size;
|
||||
$value = mb_substr($value, 0, $length);
|
||||
}
|
||||
return '0x' . $value;
|
||||
}
|
||||
}
|
@ -36,7 +36,7 @@ class DynamicBytes extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/bytes(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^bytes(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,6 +89,9 @@ class DynamicBytes extends SolidityType implements IType
|
||||
if (empty($checkZero)) {
|
||||
return '0';
|
||||
}
|
||||
return '0x' . $value;
|
||||
$size = intval(Utils::toBn(mb_substr($value, 0, 64))->toString());
|
||||
$length = 2 * $size;
|
||||
|
||||
return '0x' . mb_substr($value, 64, $length);
|
||||
}
|
||||
}
|
@ -37,7 +37,7 @@ class Integer extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/int([0-9]{1,})?(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^int([0-9]{1,})?(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -37,7 +37,7 @@ class Str extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/string(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^string(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -77,7 +77,7 @@ class Str extends SolidityType implements IType
|
||||
public function outputFormat($value, $name)
|
||||
{
|
||||
$strLen = mb_substr($value, 0, 64);
|
||||
$strValue = mb_substr($value, 64, 64);
|
||||
$strValue = mb_substr($value, 64);
|
||||
$match = [];
|
||||
|
||||
if (preg_match('/^[0]+([a-f0-9]+)$/', $strLen, $match) === 1) {
|
||||
|
@ -37,7 +37,7 @@ class Uinteger extends SolidityType implements IType
|
||||
*/
|
||||
public function isType($name)
|
||||
{
|
||||
return (preg_match('/uint([0-9]{1,})?(\[([0-9]*)\])*/', $name) === 1);
|
||||
return (preg_match('/^uint([0-9]{1,})?(\[([0-9]*)\])*$/', $name) === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -284,9 +284,7 @@ class Utils
|
||||
|
||||
if (is_array($bn)) {
|
||||
// fraction number
|
||||
list($whole, $fraction, $negative1) = $bn;
|
||||
|
||||
$fractionLength = strlen($fraction->toString());
|
||||
list($whole, $fraction, $fractionLength, $negative1) = $bn;
|
||||
|
||||
if ($fractionLength > strlen(self::UNITS[$unit])) {
|
||||
throw new InvalidArgumentException('toWei fraction part is out of limit.');
|
||||
@ -504,6 +502,7 @@ class Utils
|
||||
return [
|
||||
new BigNumber($whole),
|
||||
new BigNumber($fraction),
|
||||
strlen($comps[1]),
|
||||
isset($negative1) ? $negative1 : false
|
||||
];
|
||||
} else {
|
||||
|
@ -16,22 +16,22 @@ class BytesTypeTest extends TestCase
|
||||
protected $testTypes = [
|
||||
[
|
||||
'value' => 'bytes',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes[]',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes[4]',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes[][]',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes[3][]',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes[][6][]',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes32',
|
||||
'result' => true
|
||||
|
File diff suppressed because one or more lines are too long
@ -34,10 +34,10 @@ class DynamicBytesTypeTest extends TestCase
|
||||
'result' => true
|
||||
], [
|
||||
'value' => 'bytes32',
|
||||
'result' => true
|
||||
'result' => false
|
||||
], [
|
||||
'value' => 'bytes8[4]',
|
||||
'result' => true
|
||||
'result' => false
|
||||
],
|
||||
];
|
||||
|
||||
|
@ -9,6 +9,7 @@ use Web3\Contracts\Ethabi;
|
||||
use Web3\Contracts\Types\Address;
|
||||
use Web3\Contracts\Types\Boolean;
|
||||
use Web3\Contracts\Types\Bytes;
|
||||
use Web3\Contracts\Types\DynamicBytes;
|
||||
use Web3\Contracts\Types\Integer;
|
||||
use Web3\Contracts\Types\Str;
|
||||
use Web3\Contracts\Types\Uinteger;
|
||||
@ -179,9 +180,10 @@ class EthabiTest extends TestCase
|
||||
'address' => new Address,
|
||||
'bool' => new Boolean,
|
||||
'bytes' => new Bytes,
|
||||
'dynamicBytes' => new DynamicBytes,
|
||||
'int' => new Integer,
|
||||
'string' => new Str,
|
||||
'uint' => new Uinteger,
|
||||
'uint' => new Uinteger
|
||||
]);
|
||||
}
|
||||
|
||||
@ -293,4 +295,23 @@ class EthabiTest extends TestCase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* testIssue71
|
||||
* test 33 bytes and 128 bytes string, see: https://github.com/sc0Vu/web3.php/issues/71
|
||||
* string generated from: https://www.lipsum.com/
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testIssue71()
|
||||
{
|
||||
$abi = $this->abi;
|
||||
$specialString = 'Lorem ipsum dolor sit amet metus.';
|
||||
$encodedString = $abi->encodeParameter('string', $specialString);
|
||||
$this->assertEquals($specialString, $abi->decodeParameter('string', $encodedString));
|
||||
|
||||
$specialString = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce pulvinar quam felis, suscipit posuere neque aliquam in cras amet.';
|
||||
$encodedString = $abi->encodeParameter('string', $specialString);
|
||||
$this->assertEquals($specialString, $abi->decodeParameter('string', $encodedString));
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ class HttpProviderTest extends TestCase
|
||||
*/
|
||||
public function testSend()
|
||||
{
|
||||
$requestManager = new HttpRequestManager('http://localhost:8545');
|
||||
$requestManager = new HttpRequestManager($this->testHost);
|
||||
$provider = new HttpProvider($requestManager);
|
||||
$method = new ClientVersion('web3_clientVersion', []);
|
||||
|
||||
@ -36,7 +36,7 @@ class HttpProviderTest extends TestCase
|
||||
*/
|
||||
public function testBatch()
|
||||
{
|
||||
$requestManager = new HttpRequestManager('http://localhost:8545');
|
||||
$requestManager = new HttpRequestManager($this->testHost);
|
||||
$provider = new HttpProvider($requestManager);
|
||||
$method = new ClientVersion('web3_clientVersion', []);
|
||||
$callback = function ($err, $data) {
|
||||
|
@ -45,5 +45,11 @@ class IntegerFormatterTest extends TestCase
|
||||
|
||||
$hex = $formatter->format('1', 20);
|
||||
$this->assertEquals($hex, implode('', array_fill(0, 19, '0')) . '1');
|
||||
|
||||
$hex = $formatter->format(48);
|
||||
$this->assertEquals($hex, implode('', array_fill(0, 62, '0')) . '30');
|
||||
|
||||
$hex = $formatter->format('48');
|
||||
$this->assertEquals($hex, implode('', array_fill(0, 62, '0')) . '30');
|
||||
}
|
||||
}
|
@ -63,7 +63,7 @@ class PersonalApiTest extends TestCase
|
||||
|
||||
$personal->newAccount('123456', function ($err, $account) {
|
||||
if ($err !== null) {
|
||||
return $this->fail($e->getMessage());
|
||||
return $this->fail($err->getMessage());
|
||||
}
|
||||
$this->assertTrue(is_string($account));
|
||||
});
|
||||
@ -81,7 +81,7 @@ class PersonalApiTest extends TestCase
|
||||
// create account
|
||||
$personal->newAccount('123456', function ($err, $account) {
|
||||
if ($err !== null) {
|
||||
return $this->fail($e->getMessage());
|
||||
return $this->fail($err->getMessage());
|
||||
}
|
||||
$this->newAccount = $account;
|
||||
$this->assertTrue(is_string($account));
|
||||
@ -107,7 +107,7 @@ class PersonalApiTest extends TestCase
|
||||
// create account
|
||||
$personal->newAccount('123456', function ($err, $account) {
|
||||
if ($err !== null) {
|
||||
return $this->fail($e->getMessage());
|
||||
return $this->fail($err->getMessage());
|
||||
}
|
||||
$this->newAccount = $account;
|
||||
$this->assertTrue(is_string($account));
|
||||
@ -133,7 +133,7 @@ class PersonalApiTest extends TestCase
|
||||
// create account
|
||||
$personal->newAccount('123456', function ($err, $account) {
|
||||
if ($err !== null) {
|
||||
return $this->fail($e->getMessage());
|
||||
return $this->fail($err->getMessage());
|
||||
}
|
||||
$this->newAccount = $account;
|
||||
$this->assertTrue(is_string($account));
|
||||
|
@ -87,6 +87,16 @@ class UtilsTest extends TestCase
|
||||
$this->assertEquals('0x', Utils::toHex(0, true));
|
||||
$this->assertEquals('0x', Utils::toHex(new BigNumber(0), true));
|
||||
|
||||
$this->assertEquals('0x30', Utils::toHex(48, true));
|
||||
$this->assertEquals('0x30', Utils::toHex('48', true));
|
||||
$this->assertEquals('30', Utils::toHex(48));
|
||||
$this->assertEquals('30', Utils::toHex('48'));
|
||||
|
||||
$this->assertEquals('0x30', Utils::toHex(new BigNumber(48), true));
|
||||
$this->assertEquals('0x30', Utils::toHex(new BigNumber('48'), true));
|
||||
$this->assertEquals('30', Utils::toHex(new BigNumber(48)));
|
||||
$this->assertEquals('30', Utils::toHex(new BigNumber('48')));
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$hex = Utils::toHex(new stdClass);
|
||||
}
|
||||
@ -265,12 +275,24 @@ class UtilsTest extends TestCase
|
||||
$bn = Utils::toWei('1.69', 'ether');
|
||||
$this->assertEquals($bn->toString(), '1690000000000000000');
|
||||
|
||||
$bn = Utils::toWei('0.01', 'ether');
|
||||
$this->assertEquals($bn->toString(), '10000000000000000');
|
||||
|
||||
$bn = Utils::toWei('0.002', 'ether');
|
||||
$this->assertEquals($bn->toString(), '2000000000000000');
|
||||
|
||||
$bn = Utils::toWei(0.1, 'ether');
|
||||
$this->assertEquals($bn->toString(), '100000000000000000');
|
||||
|
||||
$bn = Utils::toWei(1.69, 'ether');
|
||||
$this->assertEquals($bn->toString(), '1690000000000000000');
|
||||
|
||||
$bn = Utils::toWei(0.01, 'ether');
|
||||
$this->assertEquals($bn->toString(), '10000000000000000');
|
||||
|
||||
$bn = Utils::toWei(0.002, 'ether');
|
||||
$this->assertEquals($bn->toString(), '2000000000000000');
|
||||
|
||||
$bn = Utils::toWei('-0.1', 'ether');
|
||||
$this->assertEquals($bn->toString(), '-100000000000000000');
|
||||
|
||||
@ -531,39 +553,45 @@ class UtilsTest extends TestCase
|
||||
$this->assertEquals($bn->toString(), '-1');
|
||||
|
||||
$bn = Utils::toBn('-0.1');
|
||||
$this->assertEquals(count($bn), 3);
|
||||
$this->assertEquals(count($bn), 4);
|
||||
$this->assertEquals($bn[0]->toString(), '0');
|
||||
$this->assertEquals($bn[1]->toString(), '1');
|
||||
$this->assertEquals($bn[2]->toString(), '-1');
|
||||
$this->assertEquals($bn[2], 1);
|
||||
$this->assertEquals($bn[3]->toString(), '-1');
|
||||
|
||||
$bn = Utils::toBn(-0.1);
|
||||
$this->assertEquals(count($bn), 3);
|
||||
$this->assertEquals(count($bn), 4);
|
||||
$this->assertEquals($bn[0]->toString(), '0');
|
||||
$this->assertEquals($bn[1]->toString(), '1');
|
||||
$this->assertEquals($bn[2]->toString(), '-1');
|
||||
$this->assertEquals($bn[2], 1);
|
||||
$this->assertEquals($bn[3]->toString(), '-1');
|
||||
|
||||
$bn = Utils::toBn('0.1');
|
||||
$this->assertEquals(count($bn), 3);
|
||||
$this->assertEquals(count($bn), 4);
|
||||
$this->assertEquals($bn[0]->toString(), '0');
|
||||
$this->assertEquals($bn[1]->toString(), '1');
|
||||
$this->assertEquals($bn[2], false);
|
||||
$this->assertEquals($bn[2], 1);
|
||||
$this->assertEquals($bn[3], false);
|
||||
|
||||
$bn = Utils::toBn('-1.69');
|
||||
$this->assertEquals(count($bn), 3);
|
||||
$this->assertEquals(count($bn), 4);
|
||||
$this->assertEquals($bn[0]->toString(), '1');
|
||||
$this->assertEquals($bn[1]->toString(), '69');
|
||||
$this->assertEquals($bn[2]->toString(), '-1');
|
||||
$this->assertEquals($bn[2], 2);
|
||||
$this->assertEquals($bn[3]->toString(), '-1');
|
||||
|
||||
$bn = Utils::toBn(-1.69);
|
||||
$this->assertEquals($bn[0]->toString(), '1');
|
||||
$this->assertEquals($bn[1]->toString(), '69');
|
||||
$this->assertEquals($bn[2]->toString(), '-1');
|
||||
$this->assertEquals($bn[2], 2);
|
||||
$this->assertEquals($bn[3]->toString(), '-1');
|
||||
|
||||
$bn = Utils::toBn('1.69');
|
||||
$this->assertEquals(count($bn), 3);
|
||||
$this->assertEquals(count($bn), 4);
|
||||
$this->assertEquals($bn[0]->toString(), '1');
|
||||
$this->assertEquals($bn[1]->toString(), '69');
|
||||
$this->assertEquals($bn[2], false);
|
||||
$this->assertEquals($bn[2], 2);
|
||||
$this->assertEquals($bn[3], false);
|
||||
|
||||
$bn = Utils::toBn(new BigNumber(1));
|
||||
$this->assertEquals($bn->toString(), '1');
|
||||
|
Loading…
Reference in New Issue
Block a user