diff --git a/packages/web3/src/codec/unlock-script-codec.ts b/packages/web3/src/codec/unlock-script-codec.ts index 17ccdafa5..f0d532345 100644 --- a/packages/web3/src/codec/unlock-script-codec.ts +++ b/packages/web3/src/codec/unlock-script-codec.ts @@ -64,3 +64,5 @@ export const unlockScriptCodec = new EnumCodec('unlock script', { P2SH: p2shCodec, SameAsPrevious: sameAsPreviousCodec }) + +export const encodedSameAsPrevious = unlockScriptCodec.encode({ kind: 'SameAsPrevious', value: 'SameAsPrevious' }) diff --git a/packages/web3/src/exchange/exchange.test.ts b/packages/web3/src/exchange/exchange.test.ts index 8dbde49f8..c9adf3cda 100644 --- a/packages/web3/src/exchange/exchange.test.ts +++ b/packages/web3/src/exchange/exchange.test.ts @@ -23,7 +23,9 @@ import { getSenderAddress, getALPHDepositInfo, validateExchangeAddress, - isALPHTransferTx + isALPHTransferTx, + getDepositInfo, + isTransferTx } from './exchange' import { NodeProvider } from '../api' @@ -202,4 +204,133 @@ describe('exchange', function () { } ]) }) + + it('should validate deposit transaction', () => { + const tokenId0 = '25469eb0d0d0a55deea832924547b7b166c70a3554fe321e81886d3c18f19d64' + const tokenOutputTemplate = { ...outputTemplate, tokens: [{ id: tokenId0, amount: '10' }] } + const unsignedTokenTxTemplate = { + ...unsignedTxTemplate, + fixedOutputs: unsignedTxTemplate.fixedOutputs.map((o) => ({ ...tokenOutputTemplate, address: o.address })) + } + const tokenTxTemplate = { ...txTemplate, unsigned: unsignedTokenTxTemplate } + expect(isALPHTransferTx(tokenTxTemplate)).toEqual(false) + expect(isTransferTx(tokenTxTemplate)).toEqual(true) + expect(isTransferTx(txTemplate)).toEqual(true) + expect(getSenderAddress(tokenTxTemplate)).toEqual(fromAddress) + + const tx0: Transaction = { ...tokenTxTemplate, unsigned: { ...unsignedTokenTxTemplate, scriptOpt: '00112233' } } + const tx1: Transaction = { ...tokenTxTemplate, contractInputs: [outputRef] } + const tx2: Transaction = { ...tokenTxTemplate, generatedOutputs: [{ ...outputTemplate, type: 'AssetOutput' }] } + const tx3: Transaction = { ...tokenTxTemplate, unsigned: { ...unsignedTokenTxTemplate, inputs: [] } } + ;[tx0, tx1, tx2, tx3].forEach((tx) => { + expect(isTransferTx(tx)).toEqual(false) + expect(getDepositInfo(tx)).toEqual({ alph: [], tokens: [] }) + }) + + const multipleTargetAddressOutputTx: Transaction = { + ...tokenTxTemplate, + unsigned: { + ...unsignedTokenTxTemplate, + fixedOutputs: [...unsignedTokenTxTemplate.fixedOutputs, { ...tokenOutputTemplate, address: exchangeAddress }] + } + } + expect(getDepositInfo(multipleTargetAddressOutputTx)).toEqual({ + alph: [{ targetAddress: exchangeAddress, depositAmount: 20n }], + tokens: [ + { + tokenId: tokenId0, + targetAddress: exchangeAddress, + depositAmount: 20n + } + ] + }) + + const sweepTx: Transaction = { + ...tokenTxTemplate, + unsigned: { + ...unsignedTokenTxTemplate, + fixedOutputs: [unsignedTokenTxTemplate.fixedOutputs[2], { ...tokenOutputTemplate, address: exchangeAddress }] + } + } + expect(getDepositInfo(sweepTx)).toEqual({ + alph: [{ targetAddress: exchangeAddress, depositAmount: 20n }], + tokens: [ + { + tokenId: tokenId0, + targetAddress: exchangeAddress, + depositAmount: 20n + } + ] + }) + + const tokenId1 = '3de370f893cb1383c828c0eb22c89aceb13fa56ddced1848db27ce7fa419c80c' + const multipleTokenTx: Transaction = { + ...tokenTxTemplate, + unsigned: { + ...unsignedTokenTxTemplate, + fixedOutputs: [ + ...unsignedTokenTxTemplate.fixedOutputs, + { ...tokenOutputTemplate, tokens: [{ id: tokenId1, amount: '10' }], address: exchangeAddress }, + { ...tokenOutputTemplate, tokens: [{ id: tokenId0, amount: '10' }], address: exchangeAddress }, + { ...tokenOutputTemplate, tokens: [{ id: tokenId1, amount: '10' }], address: exchangeAddress } + ] + } + } + expect(getDepositInfo(multipleTokenTx)).toEqual({ + alph: [{ targetAddress: exchangeAddress, depositAmount: 40n }], + tokens: [ + { + tokenId: tokenId0, + targetAddress: exchangeAddress, + depositAmount: 20n + }, + { + tokenId: tokenId1, + targetAddress: exchangeAddress, + depositAmount: 20n + } + ] + }) + + const newAddress = '1GKWggDapVjTdU2vyna3YjVgdpnwHkKzx8FHA9gU7uoeY' + const depositAlphAndTokenTx: Transaction = { + ...tokenTxTemplate, + unsigned: { + ...unsignedTokenTxTemplate, + fixedOutputs: [ + ...unsignedTokenTxTemplate.fixedOutputs, + { ...outputTemplate, address: exchangeAddress }, + { ...outputTemplate, address: newAddress }, + { ...tokenOutputTemplate, tokens: [{ id: tokenId1, amount: '10' }], address: exchangeAddress }, + { ...tokenOutputTemplate, tokens: [{ id: tokenId0, amount: '10' }], address: exchangeAddress }, + { ...tokenOutputTemplate, tokens: [{ id: tokenId1, amount: '10' }], address: exchangeAddress } + ] + } + } + + expect(getDepositInfo(depositAlphAndTokenTx)).toEqual({ + alph: [ + { + targetAddress: exchangeAddress, + depositAmount: 50n + }, + { + targetAddress: newAddress, + depositAmount: 10n + } + ], + tokens: [ + { + tokenId: tokenId0, + targetAddress: exchangeAddress, + depositAmount: 20n + }, + { + tokenId: tokenId1, + targetAddress: exchangeAddress, + depositAmount: 20n + } + ] + }) + }) }) diff --git a/packages/web3/src/exchange/exchange.ts b/packages/web3/src/exchange/exchange.ts index b8b371186..e9fa078ed 100644 --- a/packages/web3/src/exchange/exchange.ts +++ b/packages/web3/src/exchange/exchange.ts @@ -17,10 +17,10 @@ along with the library. If not, see . */ import { AddressType, addressFromPublicKey, addressFromScript } from '../address' -import { base58ToBytes, binToHex, hexToBinUnsafe, isHexString } from '../utils' +import { base58ToBytes, binToHex, HexString, hexToBinUnsafe, isHexString } from '../utils' import { Transaction } from '../api/api-alephium' import { Address } from '../signer' -import { P2SH, unlockScriptCodec } from '../codec/unlock-script-codec' +import { encodedSameAsPrevious, P2SH, unlockScriptCodec } from '../codec/unlock-script-codec' import { scriptCodec } from '../codec/script-codec' import { TraceableError } from '../error' @@ -40,19 +40,15 @@ export function isALPHTransferTx(tx: Transaction): boolean { return isTransferTx(tx) && checkALPHOutput(tx) } -export function getALPHDepositInfo(tx: Transaction): { targetAddress: Address; depositAmount: bigint }[] { - if (!isALPHTransferTx(tx)) { - return [] - } - const inputAddresses: Address[] = [] - for (const input of tx.unsigned.inputs) { - try { - const address = getAddressFromUnlockScript(input.unlockScript) - if (!inputAddresses.includes(address)) { - inputAddresses.push(address) - } - } catch (_) {} - } +export interface BaseDepositInfo { + targetAddress: Address + depositAmount: bigint +} + +export function getALPHDepositInfo(tx: Transaction): BaseDepositInfo[] { + if (!isALPHTransferTx(tx)) return [] + + const inputAddresses = getInputAddresses(tx) const result = new Map() tx.unsigned.fixedOutputs.forEach((o) => { if (!inputAddresses.includes(o.address)) { @@ -67,7 +63,63 @@ export function getALPHDepositInfo(tx: Transaction): { targetAddress: Address; d return Array.from(result.entries()).map(([key, value]) => ({ targetAddress: key, depositAmount: value })) } -// we assume that the tx is a simple transfer tx, i.e. isSimpleALPHTransferTx(tx) == true +function getInputAddresses(tx: Transaction): Address[] { + const inputAddresses: Address[] = [] + for (const input of tx.unsigned.inputs) { + try { + if (input.unlockScript === binToHex(encodedSameAsPrevious)) continue + const address = getAddressFromUnlockScript(input.unlockScript) + if (!inputAddresses.includes(address)) { + inputAddresses.push(address) + } + } catch (error) { + throw new TraceableError(`Failed to decode address from unlock script`, error) + } + } + return inputAddresses +} + +export interface TokenDepositInfo extends BaseDepositInfo { + tokenId: HexString +} + +export interface DepositInfo { + alph: BaseDepositInfo[] + tokens: TokenDepositInfo[] +} + +export function getDepositInfo(tx: Transaction): DepositInfo { + if (!isTransferTx(tx)) return { alph: [], tokens: [] } + + const inputAddresses = getInputAddresses(tx) + const alphDepositInfos = new Map() + const tokenDepositInfos = new Map>() + tx.unsigned.fixedOutputs.forEach((o) => { + if (!inputAddresses.includes(o.address)) { + const alphAmount = alphDepositInfos.get(o.address) ?? 0n + alphDepositInfos.set(o.address, alphAmount + BigInt(o.attoAlphAmount)) + + o.tokens.forEach((token) => { + const depositPerToken = tokenDepositInfos.get(token.id) ?? new Map() + const currentAmount = depositPerToken.get(o.address) ?? 0n + depositPerToken.set(o.address, currentAmount + BigInt(token.amount)) + tokenDepositInfos.set(token.id, depositPerToken) + }) + } + }) + return { + alph: Array.from(alphDepositInfos.entries()).map(([key, value]) => ({ targetAddress: key, depositAmount: value })), + tokens: Array.from(tokenDepositInfos.entries()).flatMap(([tokenId, depositPerToken]) => { + return Array.from(depositPerToken.entries()).map(([targetAddress, depositAmount]) => ({ + tokenId, + targetAddress, + depositAmount + })) + }) + } +} + +// we assume that the tx is a simple transfer tx, i.e. isALPHTransferTx(tx) || isTokenTransferTx(tx) export function getSenderAddress(tx: Transaction): Address { return getAddressFromUnlockScript(tx.unsigned.inputs[0].unlockScript) } @@ -115,7 +167,7 @@ function checkALPHOutput(tx: Transaction): boolean { return outputs.every((o) => o.tokens.length === 0) } -function isTransferTx(tx: Transaction): boolean { +export function isTransferTx(tx: Transaction): boolean { if ( tx.contractInputs.length !== 0 || tx.generatedOutputs.length !== 0 || diff --git a/packages/web3/src/exchange/index.ts b/packages/web3/src/exchange/index.ts index 0a913fb93..c9f92d269 100644 --- a/packages/web3/src/exchange/index.ts +++ b/packages/web3/src/exchange/index.ts @@ -16,4 +16,13 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -export { validateExchangeAddress, isALPHTransferTx, getSenderAddress, getALPHDepositInfo } from './exchange' +export { + validateExchangeAddress, + getSenderAddress, + isALPHTransferTx, + getALPHDepositInfo, + BaseDepositInfo, + TokenDepositInfo, + DepositInfo, + getDepositInfo +} from './exchange'