diff --git a/CHANGELOG.md b/CHANGELOG.md index bc758cd1e..f92580aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ # Changelog + ## Unreleased +### Add +- **Opt-in support for muxed accounts.** In addition to the support introduced in [v5.2.0](https://github.com/stellar/js-stellar-base/releases/v5.2.0), this completes support for muxed accounts by enabling them for fee-bump transactions. Pass a muxed account address (in the `M...` form) as the first parameter (and opting-in to muxing by passing `true` as the last parameter) to `TransactionBuilder.buildFeeBumpTransaction` to make the `feeSource` a fully-muxed account instance ([#434](https://github.com/stellar/js-stellar-base/pull/434)). + ## [v5.2.0](https://github.com/stellar/js-stellar-base/compare/v5.1.0..v5.2.0) diff --git a/src/account.js b/src/account.js index 731551a69..ca5e170bd 100644 --- a/src/account.js +++ b/src/account.js @@ -195,6 +195,16 @@ export class MuxedAccount { return this.account.incrementSequenceNumber(); } + /** + * Creates another muxed "sub"account from the base with a new ID set + * + * @param {string} id - the ID of the new muxed account + * @return {MuxedAccount} a new instance w/ the specified parameters + */ + createSubaccount(id) { + return new MuxedAccount(this.account, id); + } + /** * @return {xdr.MuxedAccount} the XDR object representing this muxed account's * G-address and uint64 ID diff --git a/src/fee_bump_transaction.js b/src/fee_bump_transaction.js index 88a27c65c..1354b39a8 100644 --- a/src/fee_bump_transaction.js +++ b/src/fee_bump_transaction.js @@ -13,12 +13,19 @@ import { encodeMuxedAccountToAddress } from './util/decode_encode_muxed_account' * Once a {@link FeeBumpTransaction} has been created, its attributes and operations * should not be changed. You should only add signatures (using {@link FeeBumpTransaction#sign}) before * submitting to the network or forwarding on to additional signers. - * @param {string|xdr.TransactionEnvelope} envelope - The transaction envelope object or base64 encoded string. - * @param {string} networkPassphrase passphrase of the target stellar network (e.g. "Public Global Stellar Network ; September 2015"). + * + * @param {string|xdr.TransactionEnvelope} envelope - transaction envelope + * object or base64 encoded string. + * @param {string} networkPassphrase - passphrase of the target Stellar network + * (e.g. "Public Global Stellar Network ; September 2015"). + * @param {bool} [opts.withMuxing] - indicates that the fee source of this + * transaction is a proper muxed account (i.e. coming from an M... address). + * By default, this option is disabled until muxed accounts are mature. + * * @extends TransactionBase */ export class FeeBumpTransaction extends TransactionBase { - constructor(envelope, networkPassphrase) { + constructor(envelope, networkPassphrase, withMuxing) { if (typeof envelope === 'string') { const buffer = Buffer.from(envelope, 'base64'); envelope = xdr.TransactionEnvelope.fromXDR(buffer); @@ -42,7 +49,10 @@ export class FeeBumpTransaction extends TransactionBase { const innerTxEnvelope = xdr.TransactionEnvelope.envelopeTypeTx( tx.innerTx().v1() ); - this._feeSource = encodeMuxedAccountToAddress(this.tx.feeSource()); + this._feeSource = encodeMuxedAccountToAddress( + this.tx.feeSource(), + withMuxing + ); this._innerTransaction = new Transaction( innerTxEnvelope, networkPassphrase diff --git a/src/keypair.js b/src/keypair.js index 9aa5ecca5..622406aab 100644 --- a/src/keypair.js +++ b/src/keypair.js @@ -1,9 +1,13 @@ import nacl from 'tweetnacl'; +import isUndefined from 'lodash/isUndefined'; +import isString from 'lodash/isString'; + import { sign, verify, generate } from './signing'; import { StrKey } from './strkey'; -import xdr from './generated/stellar-xdr_generated'; import { hash } from './hashing'; +import xdr from './generated/stellar-xdr_generated'; + /** * `Keypair` represents public (and secret) keys of the account. * @@ -121,7 +125,31 @@ export class Keypair { return new xdr.PublicKey.publicKeyTypeEd25519(this._publicKey); } - xdrMuxedAccount() { + /** + * Creates a {@link xdr.MuxedAccount} object from the public key. + * + * You will get a different type of muxed account depending on whether or not + * you pass an ID. + * + * @param {string} [id] - stringified integer indicating the underlying muxed + * ID of the new account object + * + * @return {xdr.MuxedAccount} + */ + xdrMuxedAccount(id) { + if (!isUndefined(id)) { + if (!isString(id)) { + throw new TypeError(`expected string for ID, got ${typeof id}`); + } + + return xdr.MuxedAccount.keyTypeMuxedEd25519( + new xdr.MuxedAccountMed25519({ + id: xdr.Uint64.fromString(id), + ed25519: this._publicKey + }) + ); + } + return new xdr.MuxedAccount.keyTypeEd25519(this._publicKey); } diff --git a/src/operations/payment.js b/src/operations/payment.js index 0f17d8ae0..b87f7d0cf 100644 --- a/src/operations/payment.js +++ b/src/operations/payment.js @@ -35,7 +35,7 @@ export function payment(opts) { opts.withMuxing ); } catch (e) { - throw new Error('destination is invalid'); + throw new Error('destination is invalid; did you forget to enable muxing?'); } attributes.asset = opts.asset.toXDRObject(); diff --git a/src/transaction_builder.js b/src/transaction_builder.js index 9d169cc19..067802928 100644 --- a/src/transaction_builder.js +++ b/src/transaction_builder.js @@ -2,6 +2,7 @@ import { UnsignedHyper } from 'js-xdr'; import BigNumber from 'bignumber.js'; import clone from 'lodash/clone'; import isUndefined from 'lodash/isUndefined'; +import isString from 'lodash/isString'; import xdr from './generated/stellar-xdr_generated'; import { Transaction } from './transaction'; @@ -272,18 +273,37 @@ export class TransactionBuilder { } /** - * Builds a {@link FeeBumpTransaction} - * @param {Keypair} feeSource - The account paying for the transaction. - * @param {string} baseFee - The max fee willing to pay per operation in inner transaction (**in stroops**). Required. - * @param {Transaction} innerTx - The Transaction to be bumped by the fee bump transaction. - * @param {string} networkPassphrase - networkPassphrase of the target stellar network (e.g. "Public Global Stellar Network ; September 2015"). + * Builds a {@link FeeBumpTransaction}, enabling you to resubmit an existing + * transaction with a higher fee. + * + * @param {Keypair|string} feeSource - account paying for the transaction, + * in the form of either a Keypair (only the public key is used) or + * an account ID (in G... or M... form, but refer to `withMuxing`) + * @param {string} baseFee - max fee willing to pay per operation + * in inner transaction (**in stroops**) + * @param {Transaction} innerTx - {@link Transaction} to be bumped by + * the fee bump transaction + * @param {string} networkPassphrase - passphrase of the target Stellar + * network (e.g. "Public Global Stellar Network ; September 2015") + * @param {bool} [withMuxing] - allows fee sources to be proper + * muxed accounts (i.e. coming from an M... address). By default, this + * option is disabled until muxed accounts are mature. + * + * @todo Alongside the next major version bump, this type signature can be + * changed to be less awkward: accept a MuxedAccount as the `feeSource` + * rather than a keypair or string. + * + * @note Your fee-bump amount should be 10x the original fee. + * @see https://developers.stellar.org/docs/glossary/fee-bumps/#replace-by-fee + * * @returns {FeeBumpTransaction} */ static buildFeeBumpTransaction( feeSource, baseFee, innerTx, - networkPassphrase + networkPassphrase, + withMuxing ) { const innerOps = innerTx.operations.length; const innerBaseFeeRate = new BigNumber(innerTx.fee).div(innerOps); @@ -327,8 +347,15 @@ export class TransactionBuilder { ); } + let feeSourceAccount; + if (isString(feeSource)) { + feeSourceAccount = decodeAddressToMuxedAccount(feeSource, withMuxing); + } else { + feeSourceAccount = feeSource.xdrMuxedAccount(); + } + const tx = new xdr.FeeBumpTransaction({ - feeSource: feeSource.xdrMuxedAccount(), + feeSource: feeSourceAccount, fee: xdr.Int64.fromString(base.mul(innerOps + 1).toString()), innerTx: xdr.FeeBumpTransactionInnerTx.envelopeTypeTx( innerTxEnvelope.v1() @@ -343,7 +370,7 @@ export class TransactionBuilder { feeBumpTxEnvelope ); - return new FeeBumpTransaction(envelope, networkPassphrase); + return new FeeBumpTransaction(envelope, networkPassphrase, withMuxing); } /** diff --git a/test/unit/muxed_account_test.js b/test/unit/muxed_account_test.js index f94eb89c7..57fe4d9bc 100644 --- a/test/unit/muxed_account_test.js +++ b/test/unit/muxed_account_test.js @@ -82,6 +82,9 @@ describe('muxed account abstraction works', function() { const mux1 = new StellarBase.MuxedAccount.fromAddress(MPUBKEY_ZERO, '123'); expect(mux1.id()).to.equal('0'); expect(mux1.accountId()).to.equal(MPUBKEY_ZERO); + expect(mux1.baseAccount().accountId()).to.equal( + 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ' + ); expect(mux1.sequenceNumber()).to.equal('123'); }); }); diff --git a/test/unit/transaction_builder_test.js b/test/unit/transaction_builder_test.js index 0e8e39627..7e75edbce 100644 --- a/test/unit/transaction_builder_test.js +++ b/test/unit/transaction_builder_test.js @@ -680,10 +680,11 @@ describe('TransactionBuilder', function() { ); const PUBKEY_SRC = StellarBase.StrKey.decodeEd25519PublicKey( - 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ' + source.baseAccount().accountId() ); const MUXED_SRC_ID = StellarBase.xdr.Uint64.fromString('2'); const networkPassphrase = 'Standalone Network ; February 2017'; + const signer = StellarBase.Keypair.master(StellarBase.Networks.TESTNET); it('enables muxed support after creation', function() { let builder = new StellarBase.TransactionBuilder(source, { @@ -707,7 +708,6 @@ describe('TransactionBuilder', function() { // TODO: More muxed-enabled operations ]; - const signer = StellarBase.Keypair.master(StellarBase.Networks.TESTNET); let builder = new StellarBase.TransactionBuilder(source, { fee: '100', timebounds: { minTime: 0, maxTime: 0 }, @@ -726,7 +726,7 @@ describe('TransactionBuilder', function() { tx.sign(signer); const envelope = tx.toEnvelope(); - const xdrTx = envelope.v1().tx(); + const xdrTx = envelope.value().tx(); const rawMuxedSourceAccount = xdrTx.sourceAccount(); @@ -753,5 +753,53 @@ describe('TransactionBuilder', function() { ); }).to.not.throw(); }); + + it('works with fee-bump transactions', function() { + // We create a non-muxed transaction, then fee-bump with a muxed source. + let builder = new StellarBase.TransactionBuilder(source.baseAccount(), { + fee: '100', + timebounds: { minTime: 0, maxTime: 0 }, + networkPassphrase: networkPassphrase + }); + + const muxed = new StellarBase.MuxedAccount.fromAddress(destination, '0'); + const gAddress = muxed.baseAccount().accountId(); + builder.addOperation( + StellarBase.Operation.payment({ + source: source.baseAccount().accountId(), + destination: gAddress, + amount: amount, + asset: asset + }) + ); + + let tx = builder.build(); + tx.sign(signer); + + const feeTx = StellarBase.TransactionBuilder.buildFeeBumpTransaction( + source.accountId(), + '1000', + tx, + networkPassphrase, + true + ); + + expect(feeTx).to.be.an.instanceof(StellarBase.FeeBumpTransaction); + const envelope = feeTx.toEnvelope(); + const xdrTx = envelope.value().tx(); + + const rawFeeSource = xdrTx.feeSource(); + + expect(rawFeeSource.switch()).to.equal( + StellarBase.xdr.CryptoKeyType.keyTypeMuxedEd25519() + ); + + const innerMux = rawFeeSource.med25519(); + expect(innerMux.ed25519()).to.eql(PUBKEY_SRC); + expect(encodeMuxedAccountToAddress(rawFeeSource, true)).to.equal( + source.accountId() + ); + expect(innerMux.id()).to.eql(MUXED_SRC_ID); + }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index c7e183ad5..ac6f459a4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -16,11 +16,13 @@ export class Account { export class MuxedAccount { constructor(account: Account, sequence: string); static fromAddress(mAddress: string, sequenceNum: string): MuxedAccount; + static parseBaseAddress(mAddress: string): string; /* Modeled after Account, above */ accountId(): string; sequenceNumber(): string; incrementSequenceNumber(): void; + createSubaccount(id: string): MuxedAccount; baseAccount(): Account; id(): string; @@ -99,7 +101,10 @@ export class Keypair { signDecorated(data: Buffer): xdr.DecoratedSignature; signatureHint(): Buffer; verify(data: Buffer, signature: Buffer): boolean; + xdrAccountId(): xdr.AccountId; + xdrPublicKey(): xdr.PublicKey; + xdrMuxedAccount(id: string): xdr.MuxedAccount; } export const MemoNone = 'none'; @@ -763,7 +768,8 @@ export class TransactionI { export class FeeBumpTransaction extends TransactionI { constructor( envelope: string | xdr.TransactionEnvelope, - networkPassphrase: string + networkPassphrase: string, + withMuxing?: boolean ); feeSource: string; innerTransaction: Transaction; @@ -775,7 +781,8 @@ export class Transaction< > extends TransactionI { constructor( envelope: string | xdr.TransactionEnvelope, - networkPassphrase: string + networkPassphrase: string, + withMuxing?: boolean ); memo: TMemo; operations: TOps; @@ -801,15 +808,18 @@ export class TransactionBuilder { build(): Transaction; setNetworkPassphrase(networkPassphrase: string): this; static buildFeeBumpTransaction( - feeSource: Keypair, + feeSource: Keypair | string, baseFee: string, innerTx: Transaction, - networkPassphrase: string + networkPassphrase: string, + withMuxing?: boolean ): FeeBumpTransaction; static fromXDR( envelope: string | xdr.TransactionEnvelope, networkPassphrase: string ): Transaction | FeeBumpTransaction; + + supportMuxedAccounts: boolean; } export namespace TransactionBuilder { @@ -822,6 +832,7 @@ export namespace TransactionBuilder { memo?: Memo; networkPassphrase?: string; v1?: boolean; + withMuxing?: boolean; } } diff --git a/types/test.ts b/types/test.ts index a82882e17..b7b5f7e6d 100644 --- a/types/test.ts +++ b/types/test.ts @@ -6,6 +6,8 @@ const destKey = StellarSdk.Keypair.random(); const usd = new StellarSdk.Asset('USD', 'GDGU5OAPHNPU5UCLE5RDJHG7PXZFQYWKCFOEXSXNMR6KRQRI5T6XXCD7'); // $ExpectType Asset const account = new StellarSdk.Account(sourceKey.publicKey(), '1'); // $ExpectType Account const muxedAccount = new StellarSdk.MuxedAccount(account, '123'); // $ExpectType MuxedAccount +const muxedConforms = muxedAccount as StellarSdk.Account; // $ExpectType Account + const transaction = new StellarSdk.TransactionBuilder(account, { fee: "100", networkPassphrase: StellarSdk.Networks.TESTNET