diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af38545f..28dec6d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ function encodeMuxedAccountToAddress(account: xdr.MuxedAccount, supportMuxing: b function encodeMuxedAccount(gAddress: string, id: string): xdr.MuxedAccount; ``` +- Adds a helper function `Transaction.getClaimableBalanceId(int)` which lets you pre-determine the hex claimable balance ID of a `createClaimableBalance` operation prior to submission to the network ([#482](https://github.com/stellar/js-stellar-base/pull/482)). ### Breaking Changes diff --git a/src/operations/set_trustline_flags.js b/src/operations/set_trustline_flags.js index 51c7dfb21..0a872e563 100644 --- a/src/operations/set_trustline_flags.js +++ b/src/operations/set_trustline_flags.js @@ -42,7 +42,7 @@ export function setTrustLineFlags(opts = {}) { const attributes = {}; if (typeof opts.flags !== 'object' || Object.keys(opts.flags).length === 0) { - throw new Error('opts.flags must be an map of boolean flags to modify'); + throw new Error('opts.flags must be a map of boolean flags to modify'); } const mapping = { diff --git a/src/transaction.js b/src/transaction.js index f268260bc..c2960b05c 100644 --- a/src/transaction.js +++ b/src/transaction.js @@ -1,4 +1,5 @@ import map from 'lodash/map'; + import xdr from './generated/stellar-xdr_generated'; import { hash } from './hashing'; @@ -6,7 +7,10 @@ import { StrKey } from './strkey'; import { Operation } from './operation'; import { Memo } from './memo'; import { TransactionBase } from './transaction_base'; -import { encodeMuxedAccountToAddress } from './util/decode_encode_muxed_account'; +import { + extractBaseAddress, + encodeMuxedAccountToAddress +} from './util/decode_encode_muxed_account'; /** * Use {@link TransactionBuilder} to build a transaction object. If you have an @@ -213,4 +217,53 @@ export class Transaction extends TransactionBase { return envelope; } + + /** + * Calculate the claimable balance ID for an operation within the transaction. + * + * @param {integer} opIndex the index of the CreateClaimableBalance op + * @returns {string} a hex string representing the claimable balance ID + * + * @throws {RangeError} for invalid `opIndex` value + * @throws {TypeError} if op at `opIndex` is not `CreateClaimableBalance` + * @throws for general XDR un/marshalling failures + * + * @see https://github.com/stellar/go/blob/d712346e61e288d450b0c08038c158f8848cc3e4/txnbuild/transaction.go#L392-L435 + * + */ + getClaimableBalanceId(opIndex) { + // Validate and then extract the operation from the transaction. + if ( + !Number.isInteger(opIndex) || + opIndex < 0 || + opIndex >= this.operations.length + ) { + throw new RangeError('invalid operation index'); + } + + let op = this.operations[opIndex]; + try { + op = Operation.createClaimableBalance(op); + } catch (err) { + throw new TypeError( + `expected createClaimableBalance, got ${op.type}: ${err}` + ); + } + + // Always use the transaction's *unmuxed* source. + const account = StrKey.decodeEd25519PublicKey( + extractBaseAddress(this.source) + ); + const operationId = xdr.OperationId.envelopeTypeOpId( + new xdr.OperationIdId({ + sourceAccount: xdr.AccountId.publicKeyTypeEd25519(account), + seqNum: new xdr.SequenceNumber(this.sequence), + opNum: opIndex + }) + ); + + const opIdHash = hash(operationId.toXDR('raw')); + const balanceId = xdr.ClaimableBalanceId.claimableBalanceIdTypeV0(opIdHash); + return balanceId.toXDR('hex'); + } } diff --git a/test/unit/transaction_test.js b/test/unit/transaction_test.js index af988dafc..4804da257 100644 --- a/test/unit/transaction_test.js +++ b/test/unit/transaction_test.js @@ -563,6 +563,77 @@ describe('Transaction', function() { ); }); }); + + describe('knows how to calculate claimable balance IDs', function() { + const address = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; + + const makeBuilder = function(source) { + return new StellarBase.TransactionBuilder(source, { + fee: StellarBase.BASE_FEE, + networkPassphrase: StellarBase.Networks.TESTNET, + withMuxing: true + }).setTimeout(StellarBase.TimeoutInfinite); + }; + + const makeClaimableBalance = function() { + return StellarBase.Operation.createClaimableBalance({ + asset: StellarBase.Asset.native(), + amount: '100', + claimants: [ + new StellarBase.Claimant( + address, + StellarBase.Claimant.predicateUnconditional() + ) + ] + }); + }; + + const paymentOp = StellarBase.Operation.payment({ + destination: address, + asset: StellarBase.Asset.native(), + amount: '100' + }); + + it('calculates from transaction src', function() { + let gSource = new StellarBase.Account(address, '1234'); + + let tx = makeBuilder(gSource) + .addOperation(makeClaimableBalance()) + .build(); + const balanceId = tx.getClaimableBalanceId(0); + expect(balanceId).to.be.equal( + '00000000536af35c666a28d26775008321655e9eda2039154270484e3f81d72c66d5c26f' + ); + }); + + it('calculates from muxed transaction src as if unmuxed', function() { + let gSource = new StellarBase.Account(address, '1234'); + let mSource = new StellarBase.MuxedAccount(gSource, '5678'); + let tx = makeBuilder(mSource) + .addOperation(makeClaimableBalance()) + .build(); + + const balanceId = tx.getClaimableBalanceId(0); + expect(balanceId).to.be.equal( + '00000000536af35c666a28d26775008321655e9eda2039154270484e3f81d72c66d5c26f' + ); + }); + + it('throws on invalid operations', function() { + let gSource = new StellarBase.Account(address, '1234'); + let tx = makeBuilder(gSource) + .addOperation(paymentOp) + .addOperation(makeClaimableBalance()) + .build(); + + expect(() => tx.getClaimableBalanceId(0)).to.throw( + /createClaimableBalance/ + ); + expect(() => tx.getClaimableBalanceId(1)).to.not.throw(); + expect(() => tx.getClaimableBalanceId(2)).to.throw(/index/); + expect(() => tx.getClaimableBalanceId(-1)).to.throw(/index/); + }); + }); }); function expectBuffersToBeEqual(left, right) { diff --git a/types/index.d.ts b/types/index.d.ts index 9de2bd1c2..a5c155383 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -903,6 +903,8 @@ export class Transaction< minTime: string; maxTime: string; }; + + getClaimableBalanceId(opIndex: number): string; } export const BASE_FEE = '100';