Created
June 28, 2023 15:35
-
-
Save aristidesstaffieri/87d8dc0a9b3fb4562078343056d940d9 to your computer and use it in GitHub Desktop.
JS Atomic Swap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Pre-reqs - Node.js >= 16.4 | |
// mkdir atomic-swap && cd atomic-swap | |
// npm init -y | |
// npm i bignumber.js soroban-client | |
// mkdir index.js and add this script | |
// get 3 accounts ready, deploy 2 tokens(0 decimals for simplicity), then mint at least 2 of each to each of the swapper accounts | |
// and fill in the vars in main | |
// run with node index.js | |
const SorobanClient = require('soroban-client') | |
const BigNumber = require('bignumber.js') | |
const { Buffer } = require('node:buffer') | |
const { createHash } = require('node:crypto') | |
function sha256(content) { | |
return createHash('sha256').update(content).digest('base64') | |
} | |
const accountToScVal = (account) => | |
new SorobanClient.Address(account).toScVal() | |
const numberToU64 = (value) => { | |
const bigi = BigInt(value) | |
return new SorobanClient.xdr.Uint64( | |
BigInt.asUintN(32, bigi), | |
BigInt.asUintN(64, bigi) >> 32n | |
) | |
} | |
const numberToI128 = (value) => { | |
const bigValue = BigNumber(value) | |
const b = BigInt(bigValue.toFixed(0)) | |
const buf = bigintToBuf(b) | |
if (buf.length > 16) { | |
throw new Error('BigNumber overflows i128') | |
} | |
if (bigValue.isNegative()) { | |
// Clear the top bit | |
buf[0] &= 0x7f | |
} | |
// left-pad with zeros up to 16 bytes | |
const padded = Buffer.alloc(16) | |
buf.copy(padded, padded.length - buf.length) | |
console.debug({ value: value.toString(), padded }) | |
if (bigValue.isNegative()) { | |
// Set the top bit | |
padded[0] |= 0x80 | |
} | |
const hi = new SorobanClient.xdr.Int64( | |
bigNumberFromBytes(false, ...padded.slice(4, 8)).toNumber(), | |
bigNumberFromBytes(false, ...padded.slice(0, 4)).toNumber(), | |
) | |
const lo = new SorobanClient.xdr.Uint64( | |
bigNumberFromBytes(false, ...padded.slice(12, 16)).toNumber(), | |
bigNumberFromBytes(false, ...padded.slice(8, 12)).toNumber(), | |
) | |
return SorobanClient.xdr.ScVal.scvI128( | |
new SorobanClient.xdr.Int128Parts({ lo, hi }), | |
) | |
} | |
const bigintToBuf = (bn) => { | |
let hex = BigInt(bn).toString(16).replace(/^-/, '') | |
if (hex.length % 2) { | |
hex = `0${hex}` | |
} | |
const len = hex.length / 2 | |
const u8 = new Uint8Array(len) | |
let i = 0 | |
let j = 0 | |
while (i < len) { | |
u8[i] = parseInt(hex.slice(j, j + 2), 16) | |
i += 1 | |
j += 2 | |
} | |
if (bn < BigInt(0)) { | |
// Set the top bit | |
u8[0] |= 0x80 | |
} | |
return Buffer.from(u8) | |
} | |
const bigNumberFromBytes = ( | |
signed, | |
...bytes | |
) => { | |
let sign = 1 | |
if (signed && bytes[0] === 0x80) { | |
// top bit is set, negative number. | |
sign = -1 | |
bytes[0] &= 0x7f | |
} | |
let b = BigInt(0) | |
for (const byte of bytes) { | |
b <<= BigInt(8) | |
b |= BigInt(byte) | |
} | |
return BigNumber(b.toString()).multipliedBy(sign) | |
} | |
async function assembleMultiSig( | |
raw, | |
networkPassphrase, | |
simulation, | |
signers, | |
contractId, | |
server | |
) { | |
if ('innerTransaction' in raw) { | |
// TODO: Handle feebump transactions | |
return assembleTransaction( | |
raw.innerTransaction, | |
networkPassphrase, | |
simulation, | |
) | |
} | |
if ( | |
raw.operations.length !== 1 || | |
raw.operations[0].type !== 'invokeHostFunction' | |
) { | |
throw new Error( | |
'unsupported operation type, must be only one InvokeHostFunctionOp in the transaction.', | |
) | |
} | |
const rawInvokeHostFunctionOp = raw.operations[0] | |
if ( | |
!rawInvokeHostFunctionOp.functions || | |
!simulation.results || | |
rawInvokeHostFunctionOp.functions.length !== simulation.results.length | |
) { | |
throw new Error( | |
'preflight simulation results do not contain same count of HostFunctions that InvokeHostFunctionOp in the transaction has.', | |
) | |
} | |
const source = new SorobanClient.Account(raw.source, `${parseInt(raw.sequence, 10) - 1}`) | |
const classicFeeNum = parseInt(raw.fee, 10) || 0 | |
const minResourceFeeNum = parseInt(simulation.minResourceFee, 10) || 0 | |
const txnBuilder = new SorobanClient.TransactionBuilder(source, { | |
// automatically update the tx fee that will be set on the resulting tx | |
// to the sum of 'classic' fee provided from incoming tx.fee | |
// and minResourceFee provided by simulation. | |
// | |
// 'classic' tx fees are measured as the product of tx.fee * 'number of operations', In soroban contract tx, | |
// there can only be single operation in the tx, so can make simplification | |
// of total classic fees for the soroban transaction will be equal to incoming tx.fee + minResourceFee. | |
fee: (classicFeeNum + minResourceFeeNum).toString(), | |
memo: raw.memo, | |
networkPassphrase, | |
timebounds: raw.timeBounds, | |
ledgerbounds: raw.ledgerBounds, | |
minAccountSequence: raw.minAccountSequence, | |
minAccountSequenceAge: raw.minAccountSequenceAge, | |
minAccountSequenceLedgerGap: raw.minAccountSequenceLedgerGap, | |
extraSigners: raw.extraSigners, | |
}) | |
// apply the pre-built Auth from simulation onto each Tx/Op/HostFunction | |
// invocation | |
const authDecoratedHostFunctions = simulation.results.map( | |
(functionSimulationResult, i) => { | |
const hostFn = rawInvokeHostFunctionOp.functions[i] | |
hostFn.auth(buildContractAuth(functionSimulationResult.auth, signers, networkPassphrase, contractId, server)) | |
return hostFn | |
}, | |
) | |
txnBuilder.addOperation( | |
SorobanClient.Operation.invokeHostFunctions({ | |
functions: authDecoratedHostFunctions, | |
}), | |
) | |
// apply the pre-built Soroban Tx Data from simulation onto the Tx | |
const sorobanTxData = SorobanClient.xdr.SorobanTransactionData.fromXDR( | |
simulation.transactionData, | |
'base64', | |
) | |
txnBuilder.setSorobanData(sorobanTxData) | |
return txnBuilder.build() | |
} | |
function buildContractAuth(auths, signers, networkPassphrase, contractId, server) { | |
const contractAuths = [] | |
if (auths) { | |
for (const authStr of auths) { | |
const contractAuth = SorobanClient.xdr.ContractAuth.fromXDR(authStr, 'base64') | |
console.log(contractAuth.rootInvocation().functionName().toString()) | |
console.log(contractAuth.rootInvocation().subInvocations()[0].functionName().toString()) | |
if (contractAuth.addressWithNonce()) { | |
const authAccount = contractAuth.addressWithNonce().address().accountId().toXDR('hex') | |
if (signers[authAccount]) { | |
const activeKeypair = signers[authAccount].keypair | |
let nonce = 0 | |
const key = SorobanClient.xdr.LedgerKey.contractData( | |
new SorobanClient.xdr.LedgerKeyContractData({ | |
contractId: Buffer.from(contractId).subarray(0, 32), | |
key: SorobanClient.xdr.ScVal.scvLedgerKeyNonce( | |
new SorobanClient.xdr.ScNonceKey({ | |
nonceAddress:SorobanClient.xdr.ScAddress.scAddressTypeContract( | |
Buffer.from(activeKeypair.publicKey()).subarray(0, 32) | |
) | |
}) | |
) | |
}) | |
) | |
// Fetch the current contract nonce | |
server.getLedgerEntries([key]).then(function (response) { | |
if (response.entries && response.entries.length) { | |
const ledgerEntry = response.entries[0] | |
const parsed = SorobanClient.xdr.LedgerEntryData.fromXDR(ledgerEntry.xdr, 'base64') | |
console.log( JSON.stringify(parsed) ) | |
nonce = parsed | |
} | |
}) | |
const hashIDPreimageEnvelope = SorobanClient.xdr.HashIdPreimage.envelopeTypeContractAuth( | |
new SorobanClient.xdr.HashIdPreimageContractAuth({ | |
networkId: Buffer.from(sha256(networkPassphrase)).subarray(0, 32), | |
nonce: numberToU64(nonce), | |
invocation: contractAuth.rootInvocation() | |
}) | |
).toXDR('raw') | |
const signature = activeKeypair.sign(sha256(hashIDPreimageEnvelope)) | |
// Need to double wrap with vec because of a preview 9 bug, fixed in preview 10 | |
const sigBAccountSig = SorobanClient.xdr.ScVal.scvVec( | |
[ | |
SorobanClient.xdr.ScVal.scvVec( | |
[ | |
SorobanClient.xdr.ScVal.scvMap( | |
[ | |
new SorobanClient.xdr.ScMapEntry({ | |
key: SorobanClient.xdr.ScVal.scvSymbol('public_key'), | |
val: SorobanClient.xdr.ScVal.scvBytes(activeKeypair.rawPublicKey()), | |
}), | |
new SorobanClient.xdr.ScMapEntry({ | |
key: SorobanClient.xdr.ScVal.scvSymbol('signature'), | |
val: SorobanClient.xdr.ScVal.scvBytes(Buffer.from(signature)), | |
}) | |
] | |
) | |
] | |
) | |
] | |
) | |
contractAuth.signatureArgs([sigBAccountSig]) | |
} | |
} | |
contractAuths.push(contractAuth) | |
} | |
} | |
return contractAuths | |
} | |
/* | |
Swap Args - | |
env: Env, | |
a: Address, | |
b: Address, | |
token_a: Address, | |
token_b: Address, | |
amount_a: i128, | |
min_b_for_a: i128, | |
amount_b: i128, | |
min_a_for_b: i128, | |
*/ | |
const doSwap = async ( | |
contractId, | |
tokenA, | |
tokenB, | |
amountA, | |
amountB, | |
minBForA, | |
minAForB, | |
server, | |
networkPassphrase, | |
keyPairA, | |
keyPairB, | |
deployer | |
) => { | |
const publicKeyA = keyPairA.publicKey() | |
const publicKeyB = keyPairB.publicKey() | |
const deployerKey = deployer.publicKey() | |
const accountA = await server.getAccount(publicKeyA) | |
const accountB = await server.getAccount(publicKeyB) | |
const source = await server.getAccount(deployerKey) | |
const swapContract = new SorobanClient.Contract(contractId) | |
const contractA = new SorobanClient.Contract(tokenA) | |
const contractB = new SorobanClient.Contract(tokenB) | |
const txBuilder = new SorobanClient.TransactionBuilder(source, { | |
fee: '100', | |
networkPassphrase, | |
}) | |
const tx = txBuilder | |
.addOperation( | |
swapContract.call( | |
'swap', | |
...[ | |
accountToScVal(publicKeyA), | |
accountToScVal(publicKeyB), | |
contractA.address().toScVal(), | |
contractB.address().toScVal(), | |
numberToI128(amountA), | |
numberToI128(minBForA), | |
numberToI128(amountB), | |
numberToI128(minAForB), | |
], | |
), | |
).setTimeout(SorobanClient.TimeoutInfinite) | |
try { | |
const builtTx = tx.build() | |
const sim = await server.simulateTransaction(builtTx) | |
const signers = { | |
[keyPairA.xdrPublicKey().toXDR('hex')]: { | |
keypair: keyPairA, | |
account: accountA | |
}, | |
[keyPairB.xdrPublicKey().toXDR('hex')]: { | |
keypair: keyPairB, | |
account: accountB | |
} | |
} | |
const assembledTx = await assembleMultiSig(builtTx, networkPassphrase, sim, signers, contractId, server) | |
// Do we need to re-prepare the tx? | |
const preparedTransaction = await server.prepareTransaction(assembledTx) | |
preparedTransaction.sign(deployer) | |
const transactionResult = await server.sendTransaction(preparedTransaction) | |
console.log('TX RESULT') | |
console.log(transactionResult) | |
if (transactionResult.status === 'PENDING') { | |
let txResponse = await server.getTransaction(transactionResult.hash) | |
// Poll this until the status is not 'NOT_FOUND' | |
while (txResponse.status === 'NOT_FOUND') { | |
// See if the transaction is complete | |
txResponse = await server.getTransaction(transactionResult.hash) | |
// Wait a second | |
await new Promise((resolve) => setTimeout(resolve, 1000)) | |
} | |
console.log(txResponse.resultXdr) | |
} else { | |
throw new Error( | |
`Unabled to submit transaction, status: ${transactionResult.status}`, | |
) | |
} | |
} catch (err) { | |
console.log('TX ERROR') | |
console.error(err) | |
} | |
} | |
async function main() { | |
const server = new SorobanClient.Server('https://rpc-futurenet.stellar.org/') | |
const networkPassphrase = SorobanClient.Networks.FUTURENET | |
const CONTRACT_ID = '' | |
const keyPairA = SorobanClient.Keypair.fromSecret('') | |
const keyPairB = SorobanClient.Keypair.fromSecret('') | |
const deployer = SorobanClient.Keypair.fromSecret('') | |
const tokenA = '' | |
const tokenB = '' | |
const amountA = '1' | |
const amountB = '1' | |
const minAForB = '1' | |
const minBForA = '1' | |
doSwap( | |
CONTRACT_ID, | |
tokenA, | |
tokenB, | |
amountA, | |
amountB, | |
minBForA, | |
minAForB, | |
server, | |
networkPassphrase, | |
keyPairA, | |
keyPairB, | |
deployer | |
) | |
} | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment