‘이더리움 댑 개발’ 세미나 18.1 Math in Solidity Part 1 – Part 4
디파이(DeFi) 출현으로 수를 다루는 것은 더욱 중요해졌습니다.
다음 연재 글을 참고해서 해당 내용을 정리하고자 합니다.
- Part 1: Numbers
- Part 2: Overflow
- Part 3: Percents and Proportions
- Part 4: Compound Interest
- Part 5: Exponent and Logarithm
솔리디티에는 다양한 타입이 있지만, EVM은 256비트 워드와 8비트 바이트 타입만을 지원합니다.
- Stack elements, storage keys and values, instruction and memory pointers, timestamps, balances, transaction and block hashes, addresses etc are 256-bit words.
- Most of the the EVM opcodes deal with words, including all math operations. Some of the math operations treat words as signed integers, some as unsigned integers, while other operations just work the same way regardless of whether arguments are signed on unsigned.
- Memory, byte code, call data, and return data consist of bytes.
솔리디티에는 다양한 정수 타입이 있지만, EVM 내부적으로는 부호 있는 256비트 정수와 부호 없는 256비트 정수만을 지원합니다.
솔리디티에는 다양한 fixed-point 타입은 있지만 현재 분수를 포함한 fixed-point는 지원하지 않고 있습니다.
따라서 256비트가 넘는 숫자를 다루거나 분수를 다루어야 하는 경우 별도의 조치를 해 줘야 합니다.
- 지원 범위를 넘어서는 정수(wide integers)를 표현하는 방법은 byte나 uint를 elements로 갖는 고정 또는 동적 배열을 사용하는 것입니다.
- 분수를 다루는 가장 기본적인 방법은 분모와 분자를 나타내는 정수 쌍으로 표현하는 것입니다.
- 분수를 다루는 또 다른 방법은 fixed-point로 표현하는 것입니다.
- Fixed-point는 분모가 2나 10의 거듭제곱 상수로 미리 정의된 분수입니다.
- 분모가 상수이기 때문에 분자 값만 명시하면 됩니다.
- In Solidity fixed-point numbers are usually represented as a single integer numerator, while commonly used denominators are 10¹⁸, 10²⁷, 2⁶⁴, and 2¹²⁸.
- 분모가 상수이기 때문에 분자 값만 명시하면 됩니다.
- Fixed-point는 분모가 2나 10의 거듭제곱 상수로 미리 정의된 분수입니다.
- 분수를 나타내는 또 다른 방법은 floating-point로 표현하는 것입니다.
- m × Be
- m과 e는 정수이고, B는 2나 10으로 미리 정의된 상수입니다. m을 가수(mantissa), e는 지수(exponent), B는 기수(base)라고 합니다.
- In Solidity, these standard formats could be represented by binary types bytes2, bytes4, bytes8, bytes16, and bytes32. Alternatively, mantissa and exponent could be represented separately as a pair of integers.
- m × Be
다양한 솔리디티 구현들이 이미 있습니다. 문제는 표준화된 숫자 형식이 없기 때문에 이들 라이브러리들을 조합해서 사용할 수 없습니다.
- fixidity (decimal fixed-point with arbitrary number of decimals)
- DSMath (decimal fixed-point with 18 or 27 decimals)
- BANKEX Library (IEEE-754 octuple precision floating-point)
- ABDK Libraries (binary fixed-point and quadruple precision floating-point).
EVM에서의 실행은 런타임에 해당하므로 지금까지의 제약은 런타임에 대한 것입니다.
솔리디티는 런타임에 비해 다양한 숫자 리터럴 표현을 지원합니다. 컴파일러는 최적화를 위해 계산식을 계산할 수 있습니다.
Solidity, the primary language for smart contract development, does not go much further, in terms of math, then just exposing what EVM opcodes are able to.
- 수학 식의 계산 결과를 생각할 때 같은 식이라도, 컴파일시에 계산된 값과 런타임시에 계산된 값이 달라질 수 있음에 주의해야 합니다.
- ((7 / 11 + 3 / 13) * 22 + 1) * 39
- 런타임 시에는 39로 평가됩니다.
- 런타임 시에 나누기는 반올림을 하고, 오버플로어가 발생할 수 있습니다.
- 컴파일 시에는 705로 평가됩니다.
- 솔리디티는 유리수를 지원하기 때문에 7/11의 값을 소숫점까지 계산합니다.
- 런타임 시에는 39로 평가됩니다.
- Even more interesting, the following expression is is valid in Solidity: 7523 /48124631 * 6397, while this is not valid: 7523 / 48125631 * 6397. The difference is that the former evaluates to integer number, while the latter evaluates to non-integer. Remember, that Solidity doesn’t support fractions at run time, so all literals have to be integer.
- ((7 / 11 + 3 / 13) * 22 + 1) * 39
EVM 산술 연산은 오버플로어 가능성을 내포하기 때문에
- 산술 연산자가 사용되는 곳에서는
- 오버플로어가 절대 발생하지 않음이 증명되었거나 오버플로어가 발생해야 하는 이유가 있지 않다면
- 오버플로어에 대응하는 코드를 추가해야 합니다.
- 솔리디티는 오버플로어가 발생하는 경우 잘린 결과를 조용히 리턴하기 때문에 더 조심해야 합니다.
나눗셈에 대해서는 그나마 안전합니다.
- 0으로 나눌 경우 예외를 리턴합니다.
- -2¹²⁷를 -1로 나누면 오버플로어가 발생합니다.
- uint라면 이러한 위험성은 제거됩니다.
EVM이 오버플로어에 안전한 연산자를 제공하지 않는한
- 오버플로어의 발생 가능성에 대응하는 코드가 필요한 경우
- +, -, *, ** 연산 만으로 계산 로직을 작성하면 안 됩니다. 오버플로어 가능성을 체크하고 오버플로어가 발생할 경우 예외 처리해야 합니다.
- 이런 상황에서 오플제플린의 SafeMath와 같은 라이브러리의 등장과 사용은 자연스러운 것입니다.
- 솔리디티 8.0부터는 오버플로어에 대응하는 코드를 컴파일러가 생성하기 때문에 SafeMath와 같은 라이브러리를 사용하지 않아도 도비니다.
- 이런 상황에서 오플제플린의 SafeMath와 같은 라이브러리의 등장과 사용은 자연스러운 것입니다.
- +, -, *, ** 연산 만으로 계산 로직을 작성하면 안 됩니다. 오버플로어 가능성을 체크하고 오버플로어가 발생할 경우 예외 처리해야 합니다.
오버플로어에는 phantom overflow라는 좀 더 복잡한 상황도 있습니다.
- 중간 연산 과정에 오버플로어가 발생할 수 있지만 최종 계산 결과는 결과 데이터 타입에 적합한 경우가 있을 수 있습니다.
- x의 3%는 x × 3 / 100으로 구할 수 있습니다.
- x × 3에서 오버플로어 가능성이 있습니다.
- x의 3%는 x 값보다 작은데도 불구하고.
- x × 3에서 오버플로어 가능성이 있습니다.
- x의 3%는 x × 3 / 100으로 구할 수 있습니다.
- Phantom overflows are much harder to detect and address than real overflows. One solution is to use wider integer type or even floating-point type for intermediate values. Another is to refactor the expression in order to make phantom overflow impossible.
다음과 같은 경우를 생각해 봅니다.
- (x * 3) / 100
(3 * x) / 100
(x / 100) * 3
(3 / 100) * x- 첫 번째 경우와 두 번째 경우의 값은 같습니다. Phantom 오버플로어 가능성이 있습니다.
- 세 번째 경우는 x 값이 작으면 덜 정확한 값을 얻게 됩니다. x 값이 변수이니 런타임 시에 x / 100은 반올림 됩니다. x 값이 작을 경우 결과는 0이 됩니다.
- 어느 정도 큰 수인 경우와 그렇지 않은 수를 구분해서 연산을 처리할 수도 있습니다.
- x > SOME_LARGE_NUMBER ? x / 100 * 3 : x * 3 / 100
- SOME_LARGE_NUMBER는 (2²⁵⁶-1)/3와 같이 구할 수 있습니다.
- 3%가 아니라 3.1415926535%라면?
- x > SOME_LARGE_NUMBER ? x / 1000000000000 * 31415926535 : x * 31415926535 / 1000000000000
- SOME_LARGE_NUMBER will become (2²⁵⁶-1)/31415926535
- 3.141592653589793238462643383279% 라면?
- 이 방법은 단순한 경우에는 괜찮지만 스케일이 잘 되지는 않습니다.
- x > SOME_LARGE_NUMBER ? x / 100 * 3 : x * 3 / 100
- 어느 정도 큰 수인 경우와 그렇지 않은 수를 구분해서 연산을 처리할 수도 있습니다.
- 네 번째 경우는 에러가 발생합니다. 리터럴 값을 가지고 계산할 수 있음으로 컴파일 시에 계산이 되기 때문에 3 / 100은 유리수 값이 되고, x가 uint라면 타입이 호환되지 않는다는 컴파일 에러가 발생합니다.
- (uint (3) / 100) * x와 같이 작성해서 컴파일 에러는 피할 수 있습니다. 하지만 결과 값은 0이 됩니다.
퍼센트와 비율을 구하려면 곱하기와 나누기를 해야 함으로 Phantom 오버플로어 가능성이 있고, 부정확한 계산 결과를 얻을 수 있습니다.
퍼센트와 비율을 구하는 안전한 함수를 작성해 봅시다.
- function mulDiv (uint x, uint y, uint z) public pure returns (uint)
- 오버플로어에 대해 대응하기 위해 SafeMath mul 함수를 사용합니다.
- return mul (x, y) / z;
- 여전히 Phantom 오버플로어 가능성이 있습니다.
- a, b, c, d는 정수이고 0≤b<z이고 0≤d<z일 때 x=a×z+b, y=c×z+d라고 하면
- x×y÷z = (a×z+b)×(c×z+d)÷z = (a×c×z²+(a×d+b×c)×z+b×d)÷z = a×c×z+a×d+b×c+b×d÷z
- a, b는 x를 z로 나누었을 때의 몫과 나머지로 구할 수 있습니다. c, d는 y를 z로 나누었을 때의 몫과 나머지로 구할 수 있습니다.
- a, b, c, d는 정수이고 0≤b<z이고 0≤d<z일 때 x=a×z+b, y=c×z+d라고 하면
- 여전히 Phantom 오버플로어 가능성이 있습니다.
- return mul (x, y) / z;
- 오버플로어에 대해 대응하기 위해 SafeMath mul 함수를 사용합니다.
- 위의 내용을 반영해서 함수 바디를 다음과 같이 작성합니다.
-
12345678function mulDiv (uint x, uint y, uint z) public pure returns (uint){uint a = x / z;uint b = x % z;uint c = y / z;uint d = y % z;return a * b * z + a * d + b * c + b * d / z;}
- 위의 덧셈과 곱셈 연산자를 SafeMath의 add와 mul 함수로 대체합니다.
- In this implementation phantom overflow is still possible, but only in the very last term: b * d / z. However, this code is guaranteed to work correctly when z≤2¹²⁸, as both, b and d are less that z, thus b×d is guaranteed to fit into 256 bits. So, this implementation could be used in cases when z is known to not exceed 2¹²⁸. One common example is fixed-point multiplication with 18 decimals: x×y÷10¹⁸
-
오버플로어 문제의 근본 원인은 중간 계산 결과가 256비트는 넘어서기 때문입니다. 따라서 이 문제에서 완전히 벗어나려면 더 넓은 범위 타입(wider type)을 도입해야 합니다.
- uint 타입의 wider type을 사용하려면 두 개의 연산이 필요합니다.
- uint×uint→wide
- wide÷uint→uint
이를 위한 fullMul과 fullDiv 함수를 작성해 봅니다.
- fullMul 함수는 두 개의 uint 값을 인자로 받아 결과로 512비트 uint 값을 리턴합니다. 물론 512비트 uint 값은 두 개의 256비트(low, high)로 구성됩니다.
-
123456789101112131415161718function fullMul (uint x, uint y) public pure returns (uint l, uint h){uint xl = uint128 (x);uint xh = x >> 128;uint yl = uint128 (y);uint yh = y >> 128;uint xlyl = xl * yl;uint xlyh = xl * yh;uint xhyl = xh * yl;uint xhyh = xh * yh;uint ll = uint128 (xlyl);uint lh = (xlyl >> 128) + uint128 (xlyh) + uint128 (xhyl);uint hl = uint128 (xhyh) + (xlyh >> 128) + (xhyl >> 128);uint hh = (xhyh >> 128);l = ll + (lh << 128);h = (lh >> 128) + hl + (hh << 128);}
-
- fullDiv 함수는 512 비트 uint 값을 low와 high로 분리된 두 부분으로 인자를 받습니다.
-
123456789101112131415161718192021222324function fullDiv (uint l, uint h, uint z) public pure returns (uint r) {require (h < z);uint zShift = mostSignificantBit (z);uint shiftedZ = z;if (zShift <= 127) zShift = 0;else{zShift -= 127;shiftedZ = (shiftedZ - 1 >> zShift) + 1;} while (h > 0){uint lShift = mostSignificantBit (h) + 1;uint hShift = 256 - lShift;uint e = ((h << hShift) + (l >> lShift)) / shiftedZ;if (lShift > zShift) e <<= (lShift - zShift);else e >>= (zShift - lShift);r += e;(uint tl, uint th) = fullMul (e, z);h -= th;if (tl > l) h -= 1;l -= tl;}r += l / z;}
-
- mostSignificantBit 함수를 작성합니다.
-
1234567891011function mostSignificantBit (uint x) public pure returns (uint r) {require (x > 0);if (x >= 2**128) { x >>= 128; r += 128; }if (x >= 2**64) { x >>= 64; r += 64; }if (x >= 2**32) { x >>= 32; r += 32; }if (x >= 2**16) { x >>= 16; r += 16; }if (x >= 2**8) { x >>= 8; r += 8; }if (x >= 2**4) { x >>= 4; r += 4; }if (x >= 2**2) { x >>= 2; r += 2; }if (x >= 2**1) { x >>= 1; r += 1; }}
-
위의 연산들은 그 복잡도 만큼 가스비도 많이 요구됩니다.
Remco Bloemen의 방법을 사용하면 좀 더 간략한 방법으로 작성할 수 있습니다.
1 2 3 4 5 6 7 |
function fullMul (uint x, uint y) public pure returns (uint l, uint h) { uint mm = mulmod (x, y, uint (-1)); l = x * y; h = mm - l; if (mm < l) h -= 1; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function mulDiv (uint x, uint y, uint z) public pure returns (uint) { (uint l, uint h) = fullMul (x, y); require (h < z); uint mm = mulmod (x, y, z); if (mm > l) h -= 1; l -= mm; uint pow2 = z & -z; z /= pow2; l /= pow2; l += h * ((-pow2) / pow2 + 1); uint r = 1; r *= 2 - z * r; r *= 2 - z * r; r *= 2 - z * r; r *= 2 - z * r; r *= 2 - z * r; r *= 2 - z * r; r *= 2 - z * r; r *= 2 - z * r; return l * r; } |
부동 소수점을 지원하는 라이브러(ABDKMathQuad)를 사용한다면 좀 더 간단해 질 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function mulDiv (uint x, uint y, uint z) public pure returns (uint) { return ABDKMathQuad.toUInt ( ABDKMathQuad.div ( ABDKMathQuad.mul ( ABDKMathQuad.fromUInt (x), ABDKMathQuad.fromUInt (y) ), ABDKMathQuad.fromUInt (z) ) ); } |
금융에서 퍼센트는 이자율(금리)에 주로 사용됩니다.
컴퓨터 프로그램에서는 interest rate 대신 interest ratio를 주로 사용합니다.
- interest rate는 3%와 같이, interest ratio는 0.03과 같이 나타냅니다.
- 이자를 계산할 때는 interest ration에 기간을 곱해서 구합니다.
이더리움의 특징 상 이자가 발생할 때 마다 이자 지급을 실행하기는 어렵습니다.
- 특정한 컨트랙트 함수를 호출하는 트랜잭션이 실행될 때 이자 계산이 되고, 이자는 원금에 더해집니다.
- 이자 계산이 이루어지지 않은 기간 동안을 반영해서 이자 계산이 이루어집니다.
- 반복문을 사용해서 원금에 이자를 더해 다시 원금을 구하고, 그 원금에 대해 이자를 구해가는 방식은 긴 기간 동안 이자 계산이 이루어지지 않은 경우 높은 가스비를 지불하게 합니다.
- 단일 계산에서는 principal += principal * ratio를 사용할 수 있고, 이중 계산에는 ratio = 2 * ratio + ratio * ratio를 사용할 수 있습니다.
- 반복을 짝수인 경우와 홀수 인 경우로 구분하고, 홀수인 경우 단일 계산을 하고, 반복 횟수를 짝수로 바꾼 후 이중 계산을 적용해 반복 횟수를 절반씩으로 줄여 나가는 방법으로 효율성을 높일 수 있습니다.
-
123456789101112function compound (uint principal, uint ratio, uint n) public pure returns (uint) {while (n > 0) {if (n % 2 == 1) {principal += principal * ratio;n -= 1;} else {ratio = 2 * ratio + ratio * ratio;n /= 2;}}return principal;}
- The code above has logarithmic complexity and works well when principal and ratio are large, so principal * ratio product has enough significant decimals for decent precision. However, if, principal and ratio are small, this code above may produce inaccurate result.
- principal += principal * ratio에서 principal이 정수이면 할당 시에 반올림 계산이 실행됩니다. 반복해서 계산이 일어날 때 마다 오차가 늘어나게 됩니다.
- 다음과 같은 방식을 사용해서 반올림에 의한 반복 오차가 누적되지 않도록 합니다.
- principal *= (1 + ratio) ** n
- 다음과 같이 구현 가능합니다.
-
12345678910111213141516171819202122function pow (int128 x, uint n) public pure returns (int128 r) {r = ABDKMath64x64.fromUInt (1);while (n > 0) {if (n % 2 == 1) {r = ABDKMath64x64.mul (r, x);n -= 1;} else {x = ABDKMath64x64.mul (x, x);n /= 2;}}}function compound (uint principal, uint ratio, uint n) public pure returns (uint) {return ABDKMath64x64.mulu (pow (ABDKMath64x64.add (ABDKMath64x64.fromUInt (1),ABDKMath64x64.divu (ratio, 10**18)),n),principal);}
-
- The code above is quite precise and straightforward, but it works for discrete time intervals only.
- The code above has logarithmic complexity and works well when principal and ratio are large, so principal * ratio product has enough significant decimals for decent precision. However, if, principal and ratio are small, this code above may produce inaccurate result.
-
- 반복을 짝수인 경우와 홀수 인 경우로 구분하고, 홀수인 경우 단일 계산을 하고, 반복 횟수를 짝수로 바꾼 후 이중 계산을 적용해 반복 횟수를 절반씩으로 줄여 나가는 방법으로 효율성을 높일 수 있습니다.
- 단일 계산에서는 principal += principal * ratio를 사용할 수 있고, 이중 계산에는 ratio = 2 * ratio + ratio * ratio를 사용할 수 있습니다.
실세계에서 시간은 연속적입니다. 이더리움에서 시간도 블록타임에 의해 이산적으로 보입니다.
시간 단위를 초 단위로 하고, 초 사이의 값에는 누구도 접근할 수 없다면 1초 단위로 시간은 연속적이게 됩니다.
초 단위 복리 계산은 처음에는 이상해 보일 수 있는데 이더리움에서는 놀랍게도 잘 동작합니다.
- 연간 이자율 3 %는 초당 interest rate 0.000000093668115524 % 또는 소수점 18 자리로 표현 된 초당 interest ration 0.000000000936681155와 실질적으로 동일합니다. 여기서 우리는 1 년을 31556952 초로 가정합니다.
- Using 128.128-bit fixed point numbers instead of 64.64-bit, or even floating point could allow achieving even higher precision.
- 계산을 위해 90K 정도의 높은 가스비가 요구됩니다.
- Using 128.128-bit fixed point numbers instead of 64.64-bit, or even floating point could allow achieving even higher precision.